diff --git a/src/ai-coach/ai-coach.controller.ts b/src/ai-coach/ai-coach.controller.ts index ca3ce5c..a4e0052 100644 --- a/src/ai-coach/ai-coach.controller.ts +++ b/src/ai-coach/ai-coach.controller.ts @@ -33,39 +33,8 @@ export class AiCoachController { userContent, }); - let weightInfo: { weightKg?: number; systemNotice?: string } = {}; - - // 体重识别逻辑优化: - // 1. 如果有图片URL,使用原有的图片识别逻辑 - // 2. 如果没有图片URL,但文本中包含体重信息,使用新的文本识别逻辑 - try { - if (body.imageUrl) { - // 原有逻辑:从图片识别体重 - const imageWeightInfo = await this.aiCoachService.maybeExtractAndUpdateWeight( - userId, - body.imageUrl, - userContent, - ); - if (imageWeightInfo.weightKg) { - weightInfo = { - weightKg: imageWeightInfo.weightKg, - systemNotice: `系统提示:已从图片识别体重为${imageWeightInfo.weightKg}kg,并已为你更新到个人资料。` - }; - } - } else { - // 新逻辑:从文本识别体重,并获取历史对比信息 - const textWeightInfo = await this.aiCoachService.processWeightFromText(userId, userContent); - if (textWeightInfo.weightKg && textWeightInfo.systemNotice) { - weightInfo = { - weightKg: textWeightInfo.weightKg, - systemNotice: textWeightInfo.systemNotice - }; - } - } - } catch (error) { - // 体重识别失败不影响正常对话 - console.error('体重识别失败:', error); - } + // 体重和饮食指令处理现在已经集成到 streamChat 方法中 + // 通过 # 字符开头的指令系统进行统一处理 if (!stream) { // 非流式:聚合后一次性返回文本 @@ -73,14 +42,14 @@ export class AiCoachController { userId, conversationId, userContent, - systemNotice: weightInfo.systemNotice, + imageUrl: body.imageUrl, }); let text = ''; for await (const chunk of readable) { text += chunk.toString(); } res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.send({ conversationId, text, weightKg: weightInfo.weightKg }); + res.send({ conversationId, text }); return; } @@ -93,7 +62,7 @@ export class AiCoachController { userId, conversationId, userContent, - systemNotice: weightInfo.systemNotice, + imageUrl: body.imageUrl, }); readable.on('data', (chunk) => { diff --git a/src/ai-coach/ai-coach.service.ts b/src/ai-coach/ai-coach.service.ts index a8e47ae..ae05aef 100644 --- a/src/ai-coach/ai-coach.service.ts +++ b/src/ai-coach/ai-coach.service.ts @@ -8,7 +8,7 @@ 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)兼营养分析师(Nutrition Analyst),我拥有丰富的专业知识,包括但不限于: +const SYSTEM_PROMPT = `作为一名资深的健康管家兼营养分析师(Nutrition Analyst)和健身教练,我拥有丰富的专业知识,包括但不限于: 运动领域:运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练、运动损伤预防与恢复。 @@ -76,6 +76,16 @@ const NUTRITION_ANALYST_PROMPT = `营养分析师模式(仅在检测为营养/ - 无法确定时给出区间与假设,并提示用户完善信息。 `; +/** + * 指令解析结果接口 + */ +interface CommandResult { + isCommand: boolean; + command?: 'weight' | 'diet'; + originalText: string; + cleanText: string; +} + @Injectable() export class AiCoachService { private readonly logger = new Logger(AiCoachService.name); @@ -131,27 +141,114 @@ export class AiCoachService { return messages; }; + /** + * 构建包含用户体重信息的系统提示 + */ + private async buildUserWeightContext(userId: string): Promise { + try { + const { currentWeight, history } = await this.getUserWeightHistory(userId, 10); + + if (!currentWeight && history.length === 0) { + return ''; + } + + let context = '\n\n=== 用户体重信息 ===\n'; + + if (currentWeight) { + context += `当前体重:${currentWeight}kg\n`; + } + + if (history.length > 0) { + context += `体重历史记录(最近${Math.min(history.length, 10)}次):\n`; + + // 按时间倒序显示最近的记录 + const recentHistory = history.slice(0, 10).reverse(); + recentHistory.forEach((record, index) => { + const date = new Date(record.createdAt).toLocaleDateString('zh-CN'); + context += `${index + 1}. ${date}: ${record.weight}kg (${record.source === 'manual' ? '手动记录' : record.source === 'vision' ? '视觉识别' : '其他'})\n`; + }); + + // 分析体重趋势 + if (recentHistory.length >= 2) { + const trend = this.analyzeWeightTrend(recentHistory, currentWeight || recentHistory[0].weight); + context += `\n体重趋势分析:${trend}\n`; + + // 计算变化幅度 + const firstWeight = recentHistory[recentHistory.length - 1].weight; + const lastWeight = recentHistory[0].weight; + const totalChange = lastWeight - firstWeight; + const changePercent = ((totalChange / firstWeight) * 100).toFixed(1); + + if (Math.abs(totalChange) > 0.5) { + const changeDirection = totalChange > 0 ? '增加' : '减少'; + context += `总体变化:${changeDirection}${Math.abs(totalChange).toFixed(1)}kg (${changePercent}%)\n`; + } + } + } + + context += '\n请根据用户的体重记录趋势,提供个性化的健康建议。'; + + return context; + } catch (error) { + this.logger.error(`构建用户体重上下文失败: ${error instanceof Error ? error.message : String(error)}`); + return ''; + } + } + async streamChat(params: { userId: string; conversationId: string; userContent: string; systemNotice?: string; + imageUrl?: string; }): Promise { + // 解析指令(如果以 # 开头) + const commandResult = this.parseCommand(params.userContent); + // 上下文:系统提示 + 历史 + 当前用户消息 const messages = await this.buildChatHistory(params.userId, params.conversationId); if (params.systemNotice) { messages.unshift({ role: 'system', content: params.systemNotice }); } - if (this.isLikelyNutritionTopic(params.userContent, messages)) { - messages.unshift({ role: 'system', content: NUTRITION_ANALYST_PROMPT }); + + this.logger.log(`commandResult: ${JSON.stringify(commandResult, null, 2)}`); + + // 处理体重记录指令 + if (commandResult.command === 'weight') { + const textWeightInfo = await this.processWeightFromText(params.userId, params.userContent); + if (textWeightInfo.systemNotice) { + params.systemNotice = textWeightInfo.systemNotice; + } + + // 为体重相关话题提供用户体重信息上下文 + const weightContext = await this.buildUserWeightContext(params.userId); + if (weightContext) { + messages.unshift({ role: 'system', content: weightContext }); + } + } else if (commandResult.command === 'diet') { + // 使用视觉模型分析饮食图片 + if (params.imageUrl) { + const dietAnalysis = await this.analyzeDietImage(params.imageUrl); + messages.push({ + role: 'user', + content: `用户通过拍照记录饮食,图片分析结果如下:\n${dietAnalysis}` + }); + } + messages.unshift({ role: 'system', content: this.buildDietAnalysisPrompt() }); } + // else if (this.isLikelyNutritionTopic(params.userContent, messages)) { + // messages.unshift({ role: 'system', content: NUTRITION_ANALYST_PROMPT }); + // } + + this.logger.log(`messages: ${JSON.stringify(messages)}`); + const stream = await this.client.chat.completions.create({ model: this.model, messages, stream: true, temperature: 0.7, - max_tokens: 800, + max_tokens: 500, }); const readable = new Readable({ read() { } }); @@ -186,6 +283,8 @@ export class AiCoachService { return readable; } + + private isLikelyNutritionTopic( currentText: string | undefined, messages?: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>, @@ -231,6 +330,137 @@ export class AiCoachService { 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 + }; + } + + /** + * 构建饮食分析提示 + * @returns 饮食分析提示文本 + */ + private buildDietAnalysisPrompt(): string { + return `饮食分析专家模式: + +你是一位专业的营养分析师和饮食评估专家。用户将通过拍照记录饮食,你需要: + +1. 饮食识别: + - 仔细观察图片中的食物种类、分量 + - 识别主要食材和烹饪方式 + - 估算食物的重量或体积 + +2. 热量分析: + - 基于识别的食物,计算总热量 + - 分析三大营养素比例(蛋白质、碳水化合物、脂肪) + - 估算主要维生素和矿物质含量 + +3. 健康建议: + - 评估该餐的营养均衡性 + - 提供个性化的饮食改进建议 + - 针对用户的健康目标给出指导 + +请以结构化、清晰的方式输出结果,使用亲切专业的语言风格。`; + } + + /** + * 分析饮食图片 + * @param imageUrl 图片URL + * @returns 饮食分析结果 + */ + private async analyzeDietImage(imageUrl: string): Promise { + try { + const prompt = `请分析这张食物图片,识别其中的食物种类、分量,并提供以下信息: + +1. 食物识别: + - 主要食材名称 + - 烹饪方式 + - 食物类型(主食、蛋白质、蔬菜、水果等) + +2. 分量估算: + - 每种食物的大致重量或体积 + - 使用常见单位描述(如:100g、1碗、2片等) + +3. 营养分析: + - 估算总热量(kcal) + - 三大营养素含量(蛋白质、碳水化合物、脂肪) + - 主要维生素和矿物质 + +请以结构化、清晰的方式输出结果。`; + + const completion = await this.client.chat.completions.create({ + model: this.visionModel, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: prompt }, + { type: 'image_url', image_url: { url: imageUrl } as any }, + ] as any, + }, + ], + temperature: 0, + }); + + this.logger.log(`diet image analysis result: ${completion.choices?.[0]?.message?.content}`); + + return completion.choices?.[0]?.message?.content || '无法分析图片中的食物'; + } catch (error) { + this.logger.error(`饮食图片分析失败: ${error instanceof Error ? error.message : String(error)}`); + return '饮食图片分析失败'; + } + } + private deriveTitleIfEmpty(assistantReply: string): string | null { if (!assistantReply) return null; const firstLine = assistantReply.split(/\r?\n/).find(Boolean) || ''; @@ -376,51 +606,6 @@ export class AiCoachService { return { id: rec.id, overallScore, result }; } - private isLikelyWeightLogIntent(text: string | undefined): boolean { - if (!text) return false; - const t = text.toLowerCase(); - return /体重|称重|秤|kg|公斤|weigh|weight/.test(t); - } - - async maybeExtractAndUpdateWeight(userId: string, imageUrl?: string, userText?: string): Promise<{ weightKg?: number }> { - if (!imageUrl || !this.isLikelyWeightLogIntent(userText)) return {}; - try { - const sys = '从照片中读取电子秤的数字,单位通常为kg。仅返回JSON,例如 {"weightKg": 65.2},若无法识别,返回 {"weightKg": null}。不要添加其他文本。'; - const completion = await this.client.chat.completions.create({ - model: this.visionModel, - messages: [ - { role: 'system', content: sys }, - { - role: 'user', - content: [ - { type: 'text', text: '请从图片中提取体重(kg)。若图中单位为斤或lb,请换算为kg。' }, - { type: 'image_url', image_url: { url: imageUrl } as any }, - ] as any, - }, - ], - temperature: 0, - response_format: { type: 'json_object' } as any, - }); - const raw = completion.choices?.[0]?.message?.content || ''; - let weightKg: number | undefined; - try { - const obj = JSON.parse(raw); - weightKg = typeof obj.weightKg === 'number' ? obj.weightKg : undefined; - } catch { - const m = raw.match(/\d+(?:\.\d+)?/); - weightKg = m ? parseFloat(m[0]) : undefined; - } - if (weightKg && isFinite(weightKg) && weightKg > 0 && weightKg < 400) { - await this.usersService.addWeightByVision(userId, weightKg); - return { weightKg }; - } - return {}; - } catch (err) { - this.logger.error(`maybeExtractAndUpdateWeight error: ${err instanceof Error ? err.message : String(err)}`); - return {}; - } - } - /** * 从用户文本中识别体重信息 * 支持多种格式:65kg、65公斤、65.5kg、体重65等 @@ -432,16 +617,36 @@ export class AiCoachService { // 匹配各种体重格式的正则表达式 const patterns = [ - /(?:体重|称重|秤|重量|weight).*?(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i, + // 匹配 "#记体重:80 kg" 格式 + /#(?:记体重|体重|称重|记录体重)[::]\s*(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i, + + // 匹配带单位的体重 "80kg", "80.5公斤", "80 kg" /(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)/i, - /(?:体重|称重|秤|重量|weight).*?(\d+(?:\.\d+)?)/i, - /我(?:现在|今天)?(?:体重|重量|称重)?(?:是|为|有)?(\d+(?:\.\d+)?)/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) { - const weight = parseFloat(match[1]); + 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; @@ -546,14 +751,12 @@ export class AiCoachService { }> { if (!userText) return {}; - // 检查是否是体重记录意图 - if (!this.isLikelyWeightLogIntent(userText)) { - return {}; - } try { // 从文本中提取体重 const extractedWeight = this.extractWeightFromText(userText); + this.logger.log(`extractedWeight: ${extractedWeight}`); + if (!extractedWeight) { return {}; } @@ -564,6 +767,9 @@ export class AiCoachService { // 更新体重到数据库 await this.usersService.addWeightByVision(userId, extractedWeight); + this.logger.log(`currentWeight: ${currentWeight}`); + this.logger.log(`history: ${JSON.stringify(history)}`); + // 构建系统提示 const systemNotice = this.buildWeightUpdateSystemNotice( extractedWeight,