新增基于GLM-4.5V大模型的药品AI分析服务,为用户提供专业的用药指导和健康建议: - 新增MedicationAnalysisService服务,集成GLM视觉和文本模型 - 实现流式SSE响应,支持实时返回AI分析结果 - 药品模型新增aiAnalysis字段,持久化存储分析结果 - 添加药品识别度判断,无法识别时引导用户补充信息 - 集成用户使用次数限制,免费用户次数用完后提示开通会员 - 支持图片识别分析,结合药品外观提供更准确的建议 - 提供全面的用药指导:适应症、用法用量、注意事项、副作用等
370 lines
12 KiB
TypeScript
370 lines
12 KiB
TypeScript
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;
|
||
}
|
||
} |