新增训练计划模块,包括控制器、服务、模型及数据传输对象,更新应用模块以引入新模块,同时在AI教练模块中添加体态评估功能,支持体重识别与更新,优化用户体重历史记录管理。
This commit is contained in:
@@ -6,6 +6,7 @@ import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
||||
import { AiCoachService } from './ai-coach.service';
|
||||
import { AiChatRequestDto, AiChatResponseDto } from './dto/ai-chat.dto';
|
||||
import { PostureAssessmentRequestDto, PostureAssessmentResponseDto } from './dto/posture-assessment.dto';
|
||||
|
||||
@ApiTags('ai-coach')
|
||||
@Controller('ai-coach')
|
||||
@@ -31,19 +32,30 @@ export class AiCoachController {
|
||||
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
||||
});
|
||||
|
||||
// 智能体重识别:若疑似“记体重”且传入图片,则优先识别并更新体重
|
||||
let weightInfo: { weightKg?: number } = {};
|
||||
try {
|
||||
weightInfo = await this.aiCoachService.maybeExtractAndUpdateWeight(
|
||||
userId,
|
||||
body.imageUrl,
|
||||
body.messages?.[body.messages.length - 1]?.content,
|
||||
);
|
||||
} catch { }
|
||||
|
||||
if (!stream) {
|
||||
// 非流式:聚合后一次性返回文本
|
||||
const readable = await this.aiCoachService.streamChat({
|
||||
userId,
|
||||
conversationId,
|
||||
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
||||
systemNotice: weightInfo.weightKg ? `系统提示:已从图片识别体重为${weightInfo.weightKg}kg,并已为你更新到个人资料。` : undefined,
|
||||
});
|
||||
let text = '';
|
||||
for await (const chunk of readable) {
|
||||
text += chunk.toString();
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.send({ conversationId, text });
|
||||
res.send({ conversationId, text, weightKg: weightInfo.weightKg });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,9 +68,12 @@ export class AiCoachController {
|
||||
userId,
|
||||
conversationId,
|
||||
userContent: body.messages?.[body.messages.length - 1]?.content || '',
|
||||
systemNotice: weightInfo.weightKg ? `系统提示:已从图片识别体重为${weightInfo.weightKg}kg,并已为你更新到个人资料。` : undefined,
|
||||
});
|
||||
|
||||
readable.on('data', (chunk) => {
|
||||
// 流水首段可提示体重已更新
|
||||
// 简化处理:服务端不额外注入推送段,直接靠 systemNotice
|
||||
res.write(chunk);
|
||||
});
|
||||
readable.on('end', () => {
|
||||
@@ -101,6 +116,24 @@ export class AiCoachController {
|
||||
const ok = await this.aiCoachService.deleteConversation(user.sub, conversationId);
|
||||
return { success: ok };
|
||||
}
|
||||
|
||||
@Post('posture-assessment')
|
||||
@ApiOperation({ summary: 'AI体态评估' })
|
||||
@ApiBody({ type: PostureAssessmentRequestDto })
|
||||
async postureAssessment(
|
||||
@Body() body: PostureAssessmentRequestDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<PostureAssessmentResponseDto> {
|
||||
const res = await this.aiCoachService.assessPosture({
|
||||
userId: user.sub,
|
||||
frontImageUrl: body.frontImageUrl,
|
||||
sideImageUrl: body.sideImageUrl,
|
||||
backImageUrl: body.backImageUrl,
|
||||
heightCm: body.heightCm,
|
||||
weightKg: body.weightKg,
|
||||
});
|
||||
return res as any;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ import { AiCoachController } from './ai-coach.controller';
|
||||
import { AiCoachService } from './ai-coach.service';
|
||||
import { AiMessage } from './models/ai-message.model';
|
||||
import { AiConversation } from './models/ai-conversation.model';
|
||||
import { PostureAssessment } from './models/posture-assessment.model';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
UsersModule,
|
||||
SequelizeModule.forFeature([AiConversation, AiMessage]),
|
||||
SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]),
|
||||
],
|
||||
controllers: [AiCoachController],
|
||||
providers: [AiCoachService],
|
||||
|
||||
@@ -4,6 +4,9 @@ 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';
|
||||
|
||||
const SYSTEM_PROMPT = `作为一名资深的普拉提与运动康复教练(Pilates Coach),我拥有丰富的专业知识,包括但不限于运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练以及营养与饮食建议。请遵循以下指导原则进行交流: - **话题范围**:讨论将仅限于健康、健身、普拉提、康复、形体训练、柔韧性提升、力量训练、运动损伤预防与恢复、营养与饮食等领域。 - **拒绝回答的内容**:对于医疗诊断、情感心理支持、时政金融分析或编程等非相关或高风险问题,我会礼貌地解释为何这些不在我的专业范围内,并尝试将对话引导回上述合适的话题领域内。 - **语言风格**:我的回复将以亲切且专业的态度呈现,尽量做到条理清晰、分点阐述;当需要时,会提供可以在家轻松实践的具体步骤指南及注意事项;同时考虑到不同水平参与者的需求,特别是那些可能有轻微不适或曾受过伤的人群,我会给出相应的调整建议和安全提示。 - **个性化与安全性**:强调每个人的身体状况都是独一无二的,在提出任何锻炼计划之前都会提醒大家根据自身情况适当调整强度;如果涉及到具体的疼痛问题或是旧伤复发的情况,则强烈建议先咨询医生的意见再开始新的训练项目。 - **设备要求**:所有推荐的练习都假设参与者只有基础的家庭健身器材可用,比如瑜伽垫、弹力带或者泡沫轴等;此外还会对每项活动的大致持续时间和频率做出估计,并分享一些自我监测进步的方法。 请告诉我您具体想了解哪方面的信息,以便我能更好地为您提供帮助。`;
|
||||
|
||||
@@ -12,8 +15,9 @@ 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) {
|
||||
constructor(private readonly configService: ConfigService, private readonly usersService: UsersService) {
|
||||
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';
|
||||
|
||||
@@ -23,6 +27,7 @@ export class AiCoachService {
|
||||
});
|
||||
// 默认选择通义千问对话模型(OpenAI兼容名),可通过环境覆盖
|
||||
this.model = this.configService.get<string>('DASHSCOPE_MODEL') || 'qwen-flash';
|
||||
this.visionModel = this.configService.get<string>('DASHSCOPE_VISION_MODEL') || 'qwen-vl-plus';
|
||||
}
|
||||
|
||||
async createOrAppendMessages(params: {
|
||||
@@ -64,9 +69,13 @@ export class AiCoachService {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
userContent: string;
|
||||
systemNotice?: string;
|
||||
}): Promise<Readable> {
|
||||
// 上下文:系统提示 + 历史 + 当前用户消息
|
||||
const messages = await this.buildChatHistory(params.userId, params.conversationId);
|
||||
if (params.systemNotice) {
|
||||
messages.unshift({ role: 'system', content: params.systemNotice });
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
@@ -160,6 +169,143 @@ export class AiCoachService {
|
||||
await AiConversation.destroy({ 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 };
|
||||
}
|
||||
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,11 @@ export class AiChatRequestDto {
|
||||
@IsArray()
|
||||
messages: AiChatMessageDto[];
|
||||
|
||||
@ApiProperty({ required: false, description: '当用户要记体重时的图片URL(电子秤等)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: '是否启用流式输出', default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
45
src/ai-coach/dto/posture-assessment.dto.ts
Normal file
45
src/ai-coach/dto/posture-assessment.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsNumber, IsOptional, IsString, IsUrl, Max, Min } from 'class-validator';
|
||||
|
||||
export class PostureAssessmentRequestDto {
|
||||
@ApiProperty({ description: '正面图URL' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsUrl()
|
||||
frontImageUrl: string;
|
||||
|
||||
@ApiProperty({ description: '侧面图URL' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsUrl()
|
||||
sideImageUrl: string;
|
||||
|
||||
@ApiProperty({ description: '背面图URL' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsUrl()
|
||||
backImageUrl: string;
|
||||
|
||||
@ApiProperty({ required: false, description: '身高(cm),缺省则从用户资料读取' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
heightCm?: number;
|
||||
|
||||
@ApiProperty({ required: false, description: '体重(kg),缺省则从用户资料读取' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
weightKg?: number;
|
||||
}
|
||||
|
||||
export class PostureAssessmentResponseDto {
|
||||
@ApiProperty({ description: '评估记录ID' })
|
||||
id: number;
|
||||
@ApiProperty({ description: '整体评分(0-5)' })
|
||||
@Min(0)
|
||||
@Max(5)
|
||||
overallScore: number;
|
||||
@ApiProperty({ description: '原始JSON结果' })
|
||||
result: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ export enum RoleType {
|
||||
})
|
||||
export class AiMessage extends Model {
|
||||
@PrimaryKey
|
||||
@Column({
|
||||
type: DataType.BIGINT,
|
||||
autoIncrement: true,
|
||||
})
|
||||
declare id: number;
|
||||
|
||||
@ForeignKey(() => AiConversation)
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
|
||||
49
src/ai-coach/models/posture-assessment.model.ts
Normal file
49
src/ai-coach/models/posture-assessment.model.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Column, DataType, Model, PrimaryKey, Table } from 'sequelize-typescript';
|
||||
|
||||
@Table({
|
||||
tableName: 't_posture_assessments',
|
||||
underscored: true,
|
||||
})
|
||||
export class PostureAssessment extends Model {
|
||||
@PrimaryKey
|
||||
@Column({
|
||||
type: DataType.BIGINT,
|
||||
autoIncrement: true,
|
||||
})
|
||||
declare id: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
})
|
||||
declare userId: string;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: true })
|
||||
declare frontImageUrl: string | null;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: true })
|
||||
declare sideImageUrl: string | null;
|
||||
|
||||
@Column({ type: DataType.STRING, allowNull: true })
|
||||
declare backImageUrl: string | null;
|
||||
|
||||
@Column({ type: DataType.INTEGER, allowNull: true, comment: '身高(cm)' })
|
||||
declare heightCm: number | null;
|
||||
|
||||
@Column({ type: DataType.FLOAT, allowNull: true, comment: '体重(kg)' })
|
||||
declare weightKg: number | null;
|
||||
|
||||
@Column({ type: DataType.FLOAT, allowNull: true, comment: '整体评分(0-5)' })
|
||||
declare overallScore: number | null;
|
||||
|
||||
@Column({ type: DataType.JSON, allowNull: true, comment: '评估结果原始JSON' })
|
||||
declare result: Record<string, any> | null;
|
||||
|
||||
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
|
||||
declare createdAt: Date;
|
||||
|
||||
@Column({ type: DataType.DATE, defaultValue: DataType.NOW })
|
||||
declare updatedAt: Date;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user