From 7ce51409af77c0dc918a46cd97dfb769b9eca4f4 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 1 Dec 2025 11:21:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(medications):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E8=8D=AFAI=E6=80=BB=E7=BB=93=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E7=94=9F=E6=88=90=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=94=A8=E8=8D=AF=E8=AE=A1=E5=88=92=E7=9A=84=E9=87=8D=E7=82=B9?= =?UTF-8?q?=E8=A7=A3=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...25-01-18-medication-ai-summaries-table.sql | 20 ++ .../dto/medication-ai-summary.dto.ts | 41 ++++ src/medications/medications.controller.ts | 57 ++++- src/medications/medications.module.ts | 4 +- src/medications/medications.service.ts | 16 +- .../models/medication-ai-summary.model.ts | 59 +++++ .../services/medication-analysis.service.ts | 208 ++++++++++++++++++ 7 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 sql-scripts/2025-01-18-medication-ai-summaries-table.sql create mode 100644 src/medications/dto/medication-ai-summary.dto.ts create mode 100644 src/medications/models/medication-ai-summary.model.ts diff --git a/sql-scripts/2025-01-18-medication-ai-summaries-table.sql b/sql-scripts/2025-01-18-medication-ai-summaries-table.sql new file mode 100644 index 0000000..f9fc17a --- /dev/null +++ b/sql-scripts/2025-01-18-medication-ai-summaries-table.sql @@ -0,0 +1,20 @@ +-- 用药 AI 总结表创建脚本 +-- 创建时间: 2025-01-18 +-- 说明: 按天存储用户的用药AI总结,避免重复调用大模型 + +CREATE TABLE IF NOT EXISTS `t_medication_ai_summaries` ( + `id` varchar(50) NOT NULL COMMENT '唯一标识', + `user_id` varchar(50) NOT NULL COMMENT '用户ID', + `summary_date` date NOT NULL COMMENT '统计日期(YYYY-MM-DD)', + `medication_analysis` json NOT NULL COMMENT '用药计划与进度统计', + `key_insights` text NOT NULL COMMENT 'AI重点解读', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uq_user_date` (`user_id`, `summary_date`), + KEY `idx_user_date` (`user_id`, `summary_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用药AI总结表(按天缓存)'; + +-- 使用说明: +-- 1) 每位用户每日最多一条记录,用于缓存当天的用药AI总结。 +-- 2) medication_analysis 字段存储 JSON 数组(medicationAnalysis 列表),key_insights 存储生成的重点解读文本。 diff --git a/src/medications/dto/medication-ai-summary.dto.ts b/src/medications/dto/medication-ai-summary.dto.ts new file mode 100644 index 0000000..76a8723 --- /dev/null +++ b/src/medications/dto/medication-ai-summary.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class MedicationPlanItemDto { + @ApiProperty({ description: '药物ID' }) + id: string; + + @ApiProperty({ description: '药物名称' }) + name: string; + + @ApiProperty({ description: '开始服药日期(YYYY-MM-DD)' }) + startDate: string; + + @ApiProperty({ description: '计划统计的天数(从开始日期到今天或计划结束日期,未来开始则为0)' }) + plannedDays: number; + + @ApiProperty({ description: '计划每日服药次数' }) + timesPerDay: number; + + @ApiProperty({ description: '计划总服药次数(plannedDays * timesPerDay)' }) + plannedDoses: number; + + @ApiProperty({ description: '已打卡完成的次数' }) + takenDoses: number; + + @ApiProperty({ description: '完成率(0-1之间的小数,保留两位)', example: 0.82 }) + completionRate: number; +} + +export class MedicationAiSummaryDto { + @ApiProperty({ + description: '当前正在服用的药物列表', + type: [MedicationPlanItemDto], + }) + medicationAnalysis: MedicationPlanItemDto[]; + + @ApiProperty({ + description: 'AI 针对当前用药搭配的重点解读(200字以内)', + example: '当前方案以控制炎症与镇痛为主,请留意胃肠不适并按时复诊,避免自行叠加非甾体药物,如出现头晕或皮疹需及时就医。', + }) + keyInsights: string; +} diff --git a/src/medications/medications.controller.ts b/src/medications/medications.controller.ts index 0e2570c..b2cbc63 100644 --- a/src/medications/medications.controller.ts +++ b/src/medications/medications.controller.ts @@ -27,6 +27,7 @@ import { MedicationRecognitionService } from './services/medication-recognition. import { UsersService } from '../users/users.service'; import { RepeatPatternEnum } from './enums/repeat-pattern.enum'; import { RecognitionStatusEnum } from './enums/recognition-status.enum'; +import { MedicationAiSummaryDto } from './dto/medication-ai-summary.dto'; /** * 药物管理控制器 @@ -83,6 +84,60 @@ export class MedicationsController { return ApiResponseDto.success(result, '查询成功'); } + @Get('ai-summary') + @ApiOperation({ + summary: '获取用药AI总结', + description: '汇总当前开启的用药计划,并生成200字以内的专业搭配建议', + }) + @ApiResponse({ + status: 200, + description: '获取成功', + type: MedicationAiSummaryDto, + }) + @ApiResponse({ + status: 403, + description: '免费使用次数已用完', + }) + async getMedicationAiSummary(@CurrentUser() user: any) { + try { + const cached = await this.analysisService.getTodaySummaryIfExists(user.sub); + if (cached) { + return ApiResponseDto.success(cached, '获取成功(缓存)'); + } + + const userUsageCount = await this.usersService.getUserUsageCount(user.sub); + if (userUsageCount <= 0) { + this.logger.warn(`用药AI总结失败 - 用户ID: ${user.sub}, 免费次数不足`); + return ApiResponseDto.error('免费使用次数已用完,请开通会员获取更多使用次数', 403); + } + + const summary = await this.analysisService.summarizeActiveMedications(user.sub); + + // 只有在实际调用大模型时才扣减次数(有开启的用药计划) + if (summary.medicationAnalysis.length > 0) { + 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)}`, + ); + } + } + + return ApiResponseDto.success(summary, '获取成功'); + } catch (error) { + this.logger.error( + `获取用药AI总结失败 - 用户ID: ${user.sub}, 错误: ${error instanceof Error ? error.message : String(error)}`, + ); + return ApiResponseDto.error( + error instanceof Error ? error.message : '获取AI总结失败', + ); + } + } + @Get(':id') @ApiOperation({ summary: '获取药物详情' }) @ApiResponse({ status: 200, description: '查询成功' }) @@ -402,4 +457,4 @@ export class MedicationsController { ); } } -} \ No newline at end of file +} diff --git a/src/medications/medications.module.ts b/src/medications/medications.module.ts index b95ebf2..d1693d8 100644 --- a/src/medications/medications.module.ts +++ b/src/medications/medications.module.ts @@ -7,6 +7,7 @@ import { ConfigModule } from '@nestjs/config'; import { Medication } from './models/medication.model'; import { MedicationRecord } from './models/medication-record.model'; import { MedicationRecognitionTask } from './models/medication-recognition-task.model'; +import { MedicationAiSummary } from './models/medication-ai-summary.model'; // Controllers import { MedicationsController } from './medications.controller'; @@ -38,6 +39,7 @@ import { UsersModule } from '../users/users.module'; Medication, MedicationRecord, MedicationRecognitionTask, + MedicationAiSummary, ]), ScheduleModule.forRoot(), // 启用定时任务 PushNotificationsModule, // 推送通知功能 @@ -64,4 +66,4 @@ import { UsersModule } from '../users/users.module'; MedicationStatsService, ], }) -export class MedicationsModule {} \ No newline at end of file +export class MedicationsModule {} diff --git a/src/medications/medications.service.ts b/src/medications/medications.service.ts index 06102a3..2dba7c9 100644 --- a/src/medications/medications.service.ts +++ b/src/medications/medications.service.ts @@ -93,6 +93,20 @@ export class MedicationsService { return { rows, total: count }; } + /** + * 获取用户所有正在进行的用药计划 + */ + async findActiveMedications(userId: string): Promise { + return this.medicationModel.findAll({ + where: { + userId, + isActive: true, + deleted: false, + }, + order: [['startDate', 'ASC']], + }); + } + /** * 根据ID获取药物详情 */ @@ -330,4 +344,4 @@ export class MedicationsService { `成功批量软删除了 ${affectedCount} 条 ${medication.id} 的记录`, ); } -} \ No newline at end of file +} diff --git a/src/medications/models/medication-ai-summary.model.ts b/src/medications/models/medication-ai-summary.model.ts new file mode 100644 index 0000000..fdc327b --- /dev/null +++ b/src/medications/models/medication-ai-summary.model.ts @@ -0,0 +1,59 @@ +import { Column, Model, Table, DataType, Index } from 'sequelize-typescript'; + +@Table({ + tableName: 't_medication_ai_summaries', + underscored: true, + paranoid: false, +}) +export class MedicationAiSummary extends Model { + @Column({ + type: DataType.STRING(50), + primaryKey: true, + comment: '唯一标识', + }) + declare id: string; + + @Index('idx_user_date') + @Column({ + type: DataType.STRING(50), + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Index('idx_user_date') + @Column({ + type: DataType.DATEONLY, + allowNull: false, + comment: '统计日期(YYYY-MM-DD)', + }) + declare summaryDate: string; + + @Column({ + type: DataType.JSON, + allowNull: false, + comment: '用药计划与进度统计', + }) + declare medicationAnalysis: any; + + @Column({ + type: DataType.TEXT, + allowNull: false, + comment: 'AI重点解读', + }) + declare keyInsights: string; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + comment: '创建时间', + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + comment: '更新时间', + }) + declare updatedAt: Date; +} diff --git a/src/medications/services/medication-analysis.service.ts b/src/medications/services/medication-analysis.service.ts index 6e0154d..9af254c 100644 --- a/src/medications/services/medication-analysis.service.ts +++ b/src/medications/services/medication-analysis.service.ts @@ -3,10 +3,20 @@ import { ConfigService } from '@nestjs/config'; import { InjectModel } from '@nestjs/sequelize'; import { OpenAI } from 'openai'; import { Readable } from 'stream'; +import * as dayjs from 'dayjs'; +import { Op } from 'sequelize'; import { UsersService } from '../../users/users.service'; import { MedicationsService } from '../medications.service'; import { Medication } from '../models/medication.model'; +import { MedicationRecord } from '../models/medication-record.model'; +import { MedicationAiSummary } from '../models/medication-ai-summary.model'; +import { MedicationStatusEnum } from '../enums/medication-status.enum'; import { AiAnalysisResultDto } from '../dto/ai-analysis-result.dto'; +import { + MedicationAiSummaryDto, + MedicationPlanItemDto, +} from '../dto/medication-ai-summary.dto'; +import { v4 as uuidv4 } from 'uuid'; interface LanguageConfig { label: string; @@ -32,6 +42,10 @@ export class MedicationAnalysisService { private readonly usersService: UsersService, @InjectModel(Medication) private readonly medicationModel: typeof Medication, + @InjectModel(MedicationRecord) + private readonly recordModel: typeof MedicationRecord, + @InjectModel(MedicationAiSummary) + private readonly summaryModel: typeof MedicationAiSummary, ) { // GLM-4.5V Configuration const glmApiKey = this.configService.get('GLM_API_KEY'); @@ -115,6 +129,124 @@ export class MedicationAnalysisService { } } + /** + * 获取当日已生成的用药总结(若存在缓存) + */ + async getTodaySummaryIfExists(userId: string): Promise { + const summaryDate = dayjs().format('YYYY-MM-DD'); + return this.getCachedSummary(userId, summaryDate); + } + + /** + * 总结用户当前所有开启的用药方案,并生成AI重点解读 + * @param userId 用户ID + */ + async summarizeActiveMedications(userId: string): Promise { + const summaryDate = dayjs().format('YYYY-MM-DD'); + const summaryDay = dayjs(summaryDate); + const today = summaryDay.endOf('day'); + const cached = await this.getCachedSummary(userId, summaryDate); + if (cached) { + this.logger.log(`命中用药AI总结缓存 - 用户ID: ${userId}, 日期: ${summaryDate}`); + return cached; + } + + const activeMedications = await this.medicationsService.findActiveMedications( + userId, + ); + + const medicationAnalysis: MedicationPlanItemDto[] = await Promise.all( + activeMedications.map(async (medication) => { + const startDate = dayjs(medication.startDate).startOf('day'); + const planEnd = medication.endDate + ? dayjs(medication.endDate).endOf('day') + : today; + const effectiveEnd = planEnd.isAfter(today) ? today : planEnd; + + // 未来开始的计划,天数与计划剂量均为0 + const plannedDays = startDate.isAfter(effectiveEnd) + ? 0 + : Math.max(effectiveEnd.diff(startDate, 'day') + 1, 0); + const plannedDoses = plannedDays * medication.timesPerDay; + + // 统计已完成的打卡次数(只统计 TAKEN) + let takenDoses = 0; + if (plannedDoses > 0) { + takenDoses = await this.recordModel.count({ + where: { + medicationId: medication.id, + userId, + deleted: false, + status: MedicationStatusEnum.TAKEN, + scheduledTime: { + [Op.between]: [startDate.toDate(), effectiveEnd.toDate()], + }, + }, + }); + } + + const completionRate = + plannedDoses > 0 + ? Math.round((takenDoses / plannedDoses) * 100) / 100 + : 0; + + return { + id: medication.id, + name: medication.name, + startDate: startDate.format('YYYY-MM-DD'), + plannedDays, + timesPerDay: medication.timesPerDay, + plannedDoses, + takenDoses, + completionRate, + }; + }), + ); + + this.logger.log( + `用户 ${userId} 的用药计划分析结果: ${JSON.stringify(medicationAnalysis, null, 2)}`, + ); + + // 没有开启的用药计划时,不调用大模型 + if (!medicationAnalysis.length) { + const result = { + medicationAnalysis, + keyInsights: '当前没有开启的用药计划,请先添加或激活用药后再查看解读。', + }; + await this.saveSummary(userId, summaryDate, result); + return result; + } + + const prompt = this.buildMedicationSummaryPrompt(medicationAnalysis); + + try { + const response = await this.client.chat.completions.create({ + model: this.model, + temperature: 0.4, + messages: [ + { + role: 'user', + content: prompt, + }, + ], + }); + + const keyInsights = response.choices?.[0]?.message?.content?.trim() || ''; + const result: MedicationAiSummaryDto = { + medicationAnalysis, + keyInsights, + }; + + await this.saveSummary(userId, summaryDate, result); + return result; + } catch (error) { + this.logger.error( + `生成用药总结失败 - 用户ID: ${userId}, 错误: ${error instanceof Error ? error.message : String(error)}`, + ); + throw new Error('生成用药总结失败,请稍后再试'); + } + } + /** * 使用视觉模型分析药品(带图片) * @param prompt 分析提示 @@ -352,6 +484,82 @@ export class MedicationAnalysisService { return readable; } + private async getCachedSummary( + userId: string, + summaryDate: string, + ): Promise { + const record = await this.summaryModel.findOne({ + where: { + userId, + summaryDate, + }, + }); + + if (!record) { + return null; + } + + return { + medicationAnalysis: record.medicationAnalysis, + keyInsights: record.keyInsights, + }; + } + + private async saveSummary( + userId: string, + summaryDate: string, + summary: MedicationAiSummaryDto, + ): Promise { + try { + const existing = await this.summaryModel.findOne({ + where: { userId, summaryDate }, + }); + + if (existing) { + await existing.update({ + medicationAnalysis: summary.medicationAnalysis, + keyInsights: summary.keyInsights, + }); + } else { + await this.summaryModel.create({ + id: uuidv4(), + userId, + summaryDate, + medicationAnalysis: summary.medicationAnalysis, + keyInsights: summary.keyInsights, + }); + } + } catch (error) { + this.logger.error( + `保存用药AI总结失败 - 用户ID: ${userId}, 日期: ${summaryDate}, 错误: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * 构建用药总结的提示词,输出200字以内的重点解读 + */ + private buildMedicationSummaryPrompt( + medications: MedicationPlanItemDto[], + ): string { + const medicationList = medications + .map( + (med, index) => + `${index + 1})${med.name} | 开始:${med.startDate} | 计划:${med.timesPerDay}次/天,至今共${med.plannedDoses}次 | 已完成:${med.takenDoses}次 | 完成率:${Math.round(med.completionRate * 100)}%`, + ) + .join('\n'); + + return `你是一名持证的临床医生与药学专家,请基于以下正在进行中的用药方案,输出中文“重点解读”一段话(200字以内),重点围绕计划 vs 实际的执行情况给出建议。 + +用药列表: +${medicationList} + +写作要求: +- 口吻:专业、温和、以患者安全为先 +- 内容:结合完成率与计划总次数,点评用药依从性、潜在风险与监测要点(胃肠道、肝肾功能、血压/血糖等),提出需要复诊或调整的建议 +- 形式:只输出一段文字,不要使用列表或分段,不要重复列出药品清单,不要添加免责声明`; + } + /** * 构建专业医药分析提示 * @param medication 药品信息