整合体重和饮食指令处理逻辑,优化AI教练服务,支持通过指令解析用户输入。更新系统提示以提供个性化健康建议,并增强饮食图片分析功能。移除冗余的体重识别逻辑,简化代码结构。

This commit is contained in:
richarjiang
2025-08-18 10:05:11 +08:00
parent e358b3d2fd
commit eb71f845e5
2 changed files with 268 additions and 93 deletions

View File

@@ -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) => {

View File

@@ -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<string> {
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<Readable> {
// 解析指令(如果以 # 开头)
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<string> {
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,