feat(medications): 添加药品AI分析功能,支持智能用药指导

新增基于GLM-4.5V大模型的药品AI分析服务,为用户提供专业的用药指导和健康建议:

- 新增MedicationAnalysisService服务,集成GLM视觉和文本模型
- 实现流式SSE响应,支持实时返回AI分析结果
- 药品模型新增aiAnalysis字段,持久化存储分析结果
- 添加药品识别度判断,无法识别时引导用户补充信息
- 集成用户使用次数限制,免费用户次数用完后提示开通会员
- 支持图片识别分析,结合药品外观提供更准确的建议
- 提供全面的用药指导:适应症、用法用量、注意事项、副作用等
This commit is contained in:
richarjiang
2025-11-12 17:07:39 +08:00
parent e6f3c79104
commit 5a9be42a93
5 changed files with 473 additions and 0 deletions

View File

@@ -4,5 +4,6 @@ import { CreateMedicationDto } from './create-medication.dto';
/**
* 更新药物 DTO
* 继承创建 DTO所有字段都是可选的
* 注意aiAnalysis 字段不包含在此 DTO 中,只能通过 AI 分析接口内部写入
*/
export class UpdateMedicationDto extends PartialType(CreateMedicationDto) {}

View File

@@ -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 : '药品分析失败',
),
);
}
}
}

View File

@@ -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,

View File

@@ -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,

View 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;
}
}