diff --git a/src/medications/dto/update-medication.dto.ts b/src/medications/dto/update-medication.dto.ts index 608c9f6..7a3165a 100644 --- a/src/medications/dto/update-medication.dto.ts +++ b/src/medications/dto/update-medication.dto.ts @@ -4,5 +4,6 @@ import { CreateMedicationDto } from './create-medication.dto'; /** * 更新药物 DTO * 继承创建 DTO,所有字段都是可选的 + * 注意:aiAnalysis 字段不包含在此 DTO 中,只能通过 AI 分析接口内部写入 */ export class UpdateMedicationDto extends PartialType(CreateMedicationDto) {} \ No newline at end of file diff --git a/src/medications/medications.controller.ts b/src/medications/medications.controller.ts index f161ea7..78edf85 100644 --- a/src/medications/medications.controller.ts +++ b/src/medications/medications.controller.ts @@ -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 : '药品分析失败', + ), + ); + } + } } \ No newline at end of file diff --git a/src/medications/medications.module.ts b/src/medications/medications.module.ts index 447847c..0750e7b 100644 --- a/src/medications/medications.module.ts +++ b/src/medications/medications.module.ts @@ -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, diff --git a/src/medications/models/medication.model.ts b/src/medications/models/medication.model.ts index 65efa65..a077859 100644 --- a/src/medications/models/medication.model.ts +++ b/src/medications/models/medication.model.ts @@ -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, diff --git a/src/medications/services/medication-analysis.service.ts b/src/medications/services/medication-analysis.service.ts new file mode 100644 index 0000000..259122c --- /dev/null +++ b/src/medications/services/medication-analysis.service.ts @@ -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('GLM_API_KEY'); + const glmBaseURL = this.configService.get('GLM_BASE_URL') || 'https://open.bigmodel.cn/api/paas/v4'; + + this.client = new OpenAI({ + apiKey: glmApiKey, + baseURL: glmBaseURL, + }); + + this.model = this.configService.get('GLM_MODEL') || 'glm-4-flash'; + this.visionModel = this.configService.get('GLM_VISION_MODEL') || 'glm-4v-plus'; + } + + /** + * 分析药品信息并返回流式响应 + * @param medicationId 药品ID + * @param userId 用户ID + * @returns 流式文本响应 + */ + async analyzeMedication(medicationId: string, userId: string): Promise { + 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 { + 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 { + 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 { + 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 = { + 'tablet': '片剂', + 'capsule': '胶囊', + 'syrup': '糖浆', + 'injection': '注射剂', + 'ointment': '软膏', + 'drops': '滴剂', + 'powder': '散剂', + 'granules': '颗粒剂', + 'other': '其他' + }; + return formNames[form] || form; + } +} \ No newline at end of file