feat(medications): 添加药品AI分析功能,支持智能用药指导
新增基于GLM-4.5V大模型的药品AI分析服务,为用户提供专业的用药指导和健康建议: - 新增MedicationAnalysisService服务,集成GLM视觉和文本模型 - 实现流式SSE响应,支持实时返回AI分析结果 - 药品模型新增aiAnalysis字段,持久化存储分析结果 - 添加药品识别度判断,无法识别时引导用户补充信息 - 集成用户使用次数限制,免费用户次数用完后提示开通会员 - 支持图片识别分析,结合药品外观提供更准确的建议 - 提供全面的用药指导:适应症、用法用量、注意事项、副作用等
This commit is contained in:
@@ -4,5 +4,6 @@ import { CreateMedicationDto } from './create-medication.dto';
|
||||
/**
|
||||
* 更新药物 DTO
|
||||
* 继承创建 DTO,所有字段都是可选的
|
||||
* 注意:aiAnalysis 字段不包含在此 DTO 中,只能通过 AI 分析接口内部写入
|
||||
*/
|
||||
export class UpdateMedicationDto extends PartialType(CreateMedicationDto) {}
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Res,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { MedicationsService } from './medications.service';
|
||||
import { CreateMedicationDto } from './dto/create-medication.dto';
|
||||
import { UpdateMedicationDto } from './dto/update-medication.dto';
|
||||
@@ -18,6 +21,8 @@ import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { ApiResponseDto } from '../base.dto';
|
||||
import { MedicationReminderService } from './services/medication-reminder.service';
|
||||
import { MedicationAnalysisService } from './services/medication-analysis.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
/**
|
||||
* 药物管理控制器
|
||||
@@ -26,9 +31,13 @@ import { MedicationReminderService } from './services/medication-reminder.servic
|
||||
@Controller('medications')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MedicationsController {
|
||||
private readonly logger = new Logger(MedicationsController.name);
|
||||
|
||||
constructor(
|
||||
private readonly medicationsService: MedicationsService,
|
||||
private readonly reminderService: MedicationReminderService,
|
||||
private readonly analysisService: MedicationAnalysisService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -136,4 +145,86 @@ export class MedicationsController {
|
||||
|
||||
return ApiResponseDto.success(medication, '激活成功');
|
||||
}
|
||||
|
||||
@Post(':id/ai-analysis')
|
||||
@ApiOperation({
|
||||
summary: '获取药品AI分析',
|
||||
description: '使用大模型分析药品信息,提供专业的用药指导、注意事项和健康建议。支持视觉识别药品图片。返回Server-Sent Events流式响应。'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '返回流式文本分析结果',
|
||||
content: {
|
||||
'text/event-stream': {
|
||||
schema: {
|
||||
type: 'string',
|
||||
example: '药品分析内容...'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 403,
|
||||
description: '免费使用次数已用完'
|
||||
})
|
||||
async getAiAnalysis(
|
||||
@CurrentUser() user: any,
|
||||
@Param('id') id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
try {
|
||||
// 检查用户免费使用次数
|
||||
const userUsageCount = await this.usersService.getUserUsageCount(user.sub);
|
||||
|
||||
// 如果用户不是VIP且免费次数不足,返回错误
|
||||
if (userUsageCount <= 0) {
|
||||
this.logger.warn(`药品AI分析失败 - 用户ID: ${user.sub}, 免费次数不足`);
|
||||
res.status(403).json(
|
||||
ApiResponseDto.error('免费使用次数已用完,请开通会员获取更多使用次数'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置SSE响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // 禁用nginx缓冲
|
||||
|
||||
// 获取分析流
|
||||
const stream = await this.analysisService.analyzeMedication(id, user.sub);
|
||||
|
||||
// 分析成功后扣减用户免费使用次数
|
||||
try {
|
||||
await this.usersService.deductUserUsageCount(user.sub, 1);
|
||||
this.logger.log(`药品AI分析成功,已扣减用户免费次数 - 用户ID: ${user.sub}, 剩余次数: ${userUsageCount - 1}`);
|
||||
} catch (deductError) {
|
||||
this.logger.error(`扣减用户免费次数失败 - 用户ID: ${user.sub}, 错误: ${deductError instanceof Error ? deductError.message : String(deductError)}`);
|
||||
// 不影响主流程,继续返回分析结果
|
||||
}
|
||||
|
||||
// 将流式数据写入响应
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
res.write(chunk.toString());
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
res.end();
|
||||
});
|
||||
|
||||
stream.on('error', (error) => {
|
||||
res.status(500).json(
|
||||
ApiResponseDto.error(
|
||||
error instanceof Error ? error.message : '分析过程中发生错误',
|
||||
),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json(
|
||||
ApiResponseDto.error(
|
||||
error instanceof Error ? error.message : '药品分析失败',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SequelizeModule } from '@nestjs/sequelize';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
// Models
|
||||
import { Medication } from './models/medication.model';
|
||||
@@ -18,6 +19,7 @@ import { MedicationStatsService } from './medication-stats.service';
|
||||
import { RecordGeneratorService } from './services/record-generator.service';
|
||||
import { StatusUpdaterService } from './services/status-updater.service';
|
||||
import { MedicationReminderService } from './services/medication-reminder.service';
|
||||
import { MedicationAnalysisService } from './services/medication-analysis.service';
|
||||
|
||||
// Import PushNotificationsModule for reminders
|
||||
import { PushNotificationsModule } from '../push-notifications/push-notifications.module';
|
||||
@@ -29,6 +31,7 @@ import { UsersModule } from '../users/users.module';
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule, // AI 配置
|
||||
SequelizeModule.forFeature([Medication, MedicationRecord]),
|
||||
ScheduleModule.forRoot(), // 启用定时任务
|
||||
PushNotificationsModule, // 推送通知功能
|
||||
@@ -46,6 +49,7 @@ import { UsersModule } from '../users/users.module';
|
||||
RecordGeneratorService,
|
||||
StatusUpdaterService,
|
||||
MedicationReminderService,
|
||||
MedicationAnalysisService, // AI 分析服务
|
||||
],
|
||||
exports: [
|
||||
MedicationsService,
|
||||
|
||||
@@ -104,6 +104,13 @@ export class Medication extends Model {
|
||||
})
|
||||
declare note: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'AI分析结果',
|
||||
})
|
||||
declare aiAnalysis: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
allowNull: false,
|
||||
|
||||
370
src/medications/services/medication-analysis.service.ts
Normal file
370
src/medications/services/medication-analysis.service.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectModel } from '@nestjs/sequelize';
|
||||
import { OpenAI } from 'openai';
|
||||
import { Readable } from 'stream';
|
||||
import { MedicationsService } from '../medications.service';
|
||||
import { Medication } from '../models/medication.model';
|
||||
|
||||
/**
|
||||
* 药品AI分析服务
|
||||
* 使用 GLM-4.5V 大模型分析药品信息,提供专业的用药指导和健康建议
|
||||
*/
|
||||
@Injectable()
|
||||
export class MedicationAnalysisService {
|
||||
private readonly logger = new Logger(MedicationAnalysisService.name);
|
||||
private readonly client: OpenAI;
|
||||
private readonly visionModel: string;
|
||||
private readonly model: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly medicationsService: MedicationsService,
|
||||
@InjectModel(Medication)
|
||||
private readonly medicationModel: typeof Medication,
|
||||
) {
|
||||
// GLM-4.5V Configuration
|
||||
const glmApiKey = this.configService.get<string>('GLM_API_KEY');
|
||||
const glmBaseURL = this.configService.get<string>('GLM_BASE_URL') || 'https://open.bigmodel.cn/api/paas/v4';
|
||||
|
||||
this.client = new OpenAI({
|
||||
apiKey: glmApiKey,
|
||||
baseURL: glmBaseURL,
|
||||
});
|
||||
|
||||
this.model = this.configService.get<string>('GLM_MODEL') || 'glm-4-flash';
|
||||
this.visionModel = this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4v-plus';
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析药品信息并返回流式响应
|
||||
* @param medicationId 药品ID
|
||||
* @param userId 用户ID
|
||||
* @returns 流式文本响应
|
||||
*/
|
||||
async analyzeMedication(medicationId: string, userId: string): Promise<Readable> {
|
||||
try {
|
||||
// 1. 获取药品信息
|
||||
const medication = await this.medicationsService.findOne(medicationId, userId);
|
||||
|
||||
// 2. 构建专业医药分析提示
|
||||
const prompt = this.buildMedicationAnalysisPrompt(medication);
|
||||
|
||||
// 3. 调用AI模型进行分析
|
||||
if (medication.photoUrl) {
|
||||
// 有图片:使用视觉模型
|
||||
return await this.analyzeWithVision(prompt, medication.photoUrl, medicationId, userId);
|
||||
} else {
|
||||
// 无图片:使用文本模型
|
||||
return await this.analyzeWithText(prompt, medicationId, userId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`药品分析失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return this.createErrorStream('药品分析失败,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用视觉模型分析药品(带图片)
|
||||
* @param prompt 分析提示
|
||||
* @param imageUrl 药品图片URL
|
||||
* @param medicationId 药品ID
|
||||
* @param userId 用户ID
|
||||
* @returns 流式响应
|
||||
*/
|
||||
private async analyzeWithVision(prompt: string, imageUrl: string, medicationId: string, userId: string): Promise<Readable> {
|
||||
try {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.visionModel,
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: imageUrl }
|
||||
} as any,
|
||||
] as any,
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
return this.createStreamFromAI(stream, medicationId, userId);
|
||||
} catch (error) {
|
||||
this.logger.error(`视觉模型调用失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return this.createErrorStream('视觉分析失败,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用文本模型分析药品(无图片)
|
||||
* @param prompt 分析提示
|
||||
* @param medicationId 药品ID
|
||||
* @param userId 用户ID
|
||||
* @returns 流式响应
|
||||
*/
|
||||
private async analyzeWithText(prompt: string, medicationId: string, userId: string): Promise<Readable> {
|
||||
try {
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
return this.createStreamFromAI(stream, medicationId, userId);
|
||||
} catch (error) {
|
||||
this.logger.error(`文本模型调用失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return this.createErrorStream('文本分析失败,请稍后重试。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从AI响应创建可读流
|
||||
* @param aiStream AI模型流式响应
|
||||
* @param medicationId 药品ID(用于保存分析结果)
|
||||
* @param userId 用户ID
|
||||
* @returns Readable stream
|
||||
*/
|
||||
private createStreamFromAI(aiStream: any, medicationId?: string, userId?: string): Readable {
|
||||
const readable = new Readable({ read() { } });
|
||||
let fullContent = ''; // 收集完整的AI响应内容
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for await (const chunk of aiStream) {
|
||||
const delta = chunk.choices?.[0]?.delta?.content || '';
|
||||
if (delta) {
|
||||
fullContent += delta; // 累积内容
|
||||
readable.push(delta);
|
||||
}
|
||||
}
|
||||
|
||||
// 流结束后保存完整的分析结果到数据库
|
||||
if (medicationId && userId && fullContent) {
|
||||
await this.saveAnalysisResult(medicationId, userId, fullContent);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`流式响应错误: ${error instanceof Error ? error.message : String(error)}`);
|
||||
readable.push('\n\n[分析过程中发生错误,请稍后重试]');
|
||||
} finally {
|
||||
readable.push(null);
|
||||
}
|
||||
})();
|
||||
|
||||
return readable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存AI分析结果到数据库
|
||||
* @param medicationId 药品ID
|
||||
* @param userId 用户ID
|
||||
* @param analysisResult 分析结果
|
||||
*/
|
||||
private async saveAnalysisResult(medicationId: string, userId: string, analysisResult: string): Promise<void> {
|
||||
try {
|
||||
// 直接更新数据库,不通过 DTO
|
||||
await this.medicationModel.update(
|
||||
{ aiAnalysis: analysisResult },
|
||||
{
|
||||
where: {
|
||||
id: medicationId,
|
||||
userId: userId,
|
||||
deleted: false
|
||||
}
|
||||
}
|
||||
);
|
||||
this.logger.log(`药品 ${medicationId} 的AI分析结果已保存到数据库`);
|
||||
} catch (error) {
|
||||
this.logger.error(`保存AI分析结果失败: ${error instanceof Error ? error.message : String(error)}`);
|
||||
// 不抛出错误,避免影响流式响应
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建错误流
|
||||
* @param errorMessage 错误信息
|
||||
* @returns Readable stream
|
||||
*/
|
||||
private createErrorStream(errorMessage: string): Readable {
|
||||
const readable = new Readable({ read() { } });
|
||||
setTimeout(() => {
|
||||
readable.push(errorMessage);
|
||||
readable.push(null);
|
||||
}, 100);
|
||||
return readable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建专业医药分析提示
|
||||
* @param medication 药品信息
|
||||
* @returns 分析提示文本
|
||||
*/
|
||||
private buildMedicationAnalysisPrompt(medication: Medication): string {
|
||||
const formName = this.getMedicationFormName(medication.form);
|
||||
const dosageInfo = `${medication.dosageValue}${medication.dosageUnit}`;
|
||||
|
||||
return `你是一位拥有20年从业经验的资深药剂师和临床医学专家,同时也是一名充满关怀的健康顾问。
|
||||
|
||||
你的专业背景包括:
|
||||
- 药理学与临床药学
|
||||
- 用药安全与药物相互作用
|
||||
- 患者用药教育与健康管理
|
||||
- 慢性病用药指导
|
||||
- 中西医结合用药
|
||||
|
||||
请基于以下药品信息,为用户提供专业、详细、易懂的药品分析报告。
|
||||
|
||||
**药品信息**:
|
||||
- 药品名称:${medication.name}
|
||||
- 剂型:${formName}
|
||||
- 规格剂量:${dosageInfo}
|
||||
- 每日服用次数:${medication.timesPerDay}次
|
||||
- 服药时间:${medication.medicationTimes.join('、')}
|
||||
${medication.photoUrl ? '- 药品图片:已提供(请结合图片中的药品外观、包装、说明书等信息进行分析)' : ''}
|
||||
${medication.note ? `- 用户备注:${medication.note}` : ''}
|
||||
|
||||
**关键分析原则**:
|
||||
⚠️ **药品识别度判断**:
|
||||
- 首先判断提供的药品名称是否是正规的、可识别的药品(包括通用名、商品名、中药名等)
|
||||
- 如果药品名称模糊、不规范、无法识别,或者明显是随意输入的内容(如"感冒药"、"止痛药"、"消炎药"、"xx片"等过于笼统的名称)
|
||||
- 如果有图片但图片信息不足以确认具体药品,结合药品名称综合判断
|
||||
- 注意:没有图片不影响分析,只要药品名称明确即可
|
||||
- 在无法准确识别药品的情况下,**不要进行药品分析**,而是友好地引导用户提供更多信息
|
||||
|
||||
**分析要求**:
|
||||
1. **药品识别优先**:先判断是否能准确识别药品,无法识别时不要随意推测或给出建议
|
||||
2. 使用温暖、专业、通俗易懂的语言
|
||||
3. 以患者的健康和安全为首要考虑
|
||||
4. 提供实用的用药指导和生活建议
|
||||
5. 强调重要的注意事项和禁忌
|
||||
6. 给予健康关怀和鼓励
|
||||
7. 如果有图片,请结合图片信息提供更准确的分析
|
||||
|
||||
**输出格式要求**:
|
||||
|
||||
**情况A:无法识别药品时**(药品名称不明确、过于笼统、随意输入、或缺少必要信息),请使用以下格式:
|
||||
|
||||
## 🤔 需要更多信息
|
||||
|
||||
很抱歉,根据您提供的信息,我无法准确识别这个药品。为了给您提供安全、专业的用药指导,我需要更多详细信息。
|
||||
|
||||
**当前信息不足的原因**:
|
||||
[说明为什么无法识别,例如:
|
||||
- 药品名称过于笼统(如"感冒药"包含多种不同成分的药物)
|
||||
- 药品名称不规范或无法在药品数据库中找到对应信息
|
||||
- 药品名称疑似随意输入,无法对应到具体药品
|
||||
- 提供的图片信息不足以确认具体药品(如有图片的话)]
|
||||
|
||||
## 💡 建议您这样做
|
||||
|
||||
为了给您提供安全、准确的用药指导,请选择以下任一方式:
|
||||
|
||||
### 方式一:补充药品完整名称 📝
|
||||
在【备注】中添加药品的完整名称,例如:
|
||||
- "阿莫西林胶囊"
|
||||
- "布洛芬缓释胶囊"
|
||||
- "999感冒灵颗粒"
|
||||
|
||||
💡 **小贴士**:可以从药盒或说明书上找到完整的药品名称
|
||||
|
||||
### 方式二:上传药品图片 📸
|
||||
拍摄清晰的照片:
|
||||
- 药品外包装(带有药品名称的一面)
|
||||
- 或药品说明书
|
||||
|
||||
图片能帮助我更准确地识别和分析药品信息。
|
||||
|
||||
---
|
||||
|
||||
补充信息后重新分析,我将为您提供专业的用药指导!💚
|
||||
|
||||
---
|
||||
|
||||
**情况B:能够识别药品时**,请严格按照以下Markdown结构输出(使用纯文本格式):
|
||||
|
||||
## 💊 药品基本信息
|
||||
[简要说明药品的通用名称、主要成分、剂型等基本信息。如果有图片,描述药品外观特征]
|
||||
|
||||
## 🎯 主要用途与适应症
|
||||
[详细说明药品的适应症和治疗目的,让患者了解为什么要服用这个药]
|
||||
|
||||
## 📋 用法用量指导
|
||||
[根据药品信息给出标准用法用量指导,包括:
|
||||
- 推荐服用时间(饭前/饭后/空腹等)
|
||||
- 服用方法(吞服/咀嚼/含服等)
|
||||
- 是否需要用水送服
|
||||
- 特殊注意事项]
|
||||
|
||||
## ⚠️ 重要注意事项
|
||||
[列出关键的注意事项,包括:
|
||||
- 禁忌症(哪些人不能用)
|
||||
- 特殊人群用药注意(孕妇、哺乳期、儿童、老年人等)
|
||||
- 可能的药物相互作用
|
||||
- 用药期间需要避免的食物或行为]
|
||||
|
||||
## 🌡️ 可能的副作用
|
||||
[说明常见和严重的副作用:
|
||||
- 常见副作用及应对方法
|
||||
- 需要立即就医的严重反应
|
||||
- 如何减轻副作用的建议]
|
||||
|
||||
## 🏠 储存与保管
|
||||
[正确的储存方法:
|
||||
- 储存温度和环境要求
|
||||
- 有效期提醒
|
||||
- 儿童接触预防]
|
||||
|
||||
## 💚 健康关怀建议
|
||||
[个性化的健康建议:
|
||||
- 配合药物治疗的生活方式建议
|
||||
- 饮食营养建议
|
||||
- 运动和作息建议
|
||||
- 心理调适建议]
|
||||
|
||||
## ⏰ 用药依从性提醒
|
||||
[帮助患者坚持用药的实用技巧:
|
||||
- 如何记住服药时间
|
||||
- 漏服后的处理方法
|
||||
- 定期复查的重要性
|
||||
- 与医生沟通的建议]
|
||||
|
||||
---
|
||||
**⚠️ 重要提醒**:
|
||||
本分析基于药品的一般信息提供参考,不能替代专业医疗建议。每个人的情况不同,请:
|
||||
- 严格遵医嘱服药
|
||||
- 如有疑问或不适,及时咨询医生或药师
|
||||
- 定期复查,根据病情调整用药
|
||||
- 不要自行增减剂量或停药
|
||||
|
||||
祝您早日康复,保持健康!💪`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取药品剂型的中文名称
|
||||
* @param form 剂型枚举
|
||||
* @returns 中文名称
|
||||
*/
|
||||
private getMedicationFormName(form: string): string {
|
||||
const formNames: Record<string, string> = {
|
||||
'tablet': '片剂',
|
||||
'capsule': '胶囊',
|
||||
'syrup': '糖浆',
|
||||
'injection': '注射剂',
|
||||
'ointment': '软膏',
|
||||
'drops': '滴剂',
|
||||
'powder': '散剂',
|
||||
'granules': '颗粒剂',
|
||||
'other': '其他'
|
||||
};
|
||||
return formNames[form] || form;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user