feat(medications): 添加用药AI总结功能,支持生成用户用药计划的重点解读
This commit is contained in:
20
sql-scripts/2025-01-18-medication-ai-summaries-table.sql
Normal file
20
sql-scripts/2025-01-18-medication-ai-summaries-table.sql
Normal file
@@ -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 存储生成的重点解读文本。
|
||||
41
src/medications/dto/medication-ai-summary.dto.ts
Normal file
41
src/medications/dto/medication-ai-summary.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
export class MedicationsModule {}
|
||||
|
||||
@@ -93,6 +93,20 @@ export class MedicationsService {
|
||||
return { rows, total: count };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所有正在进行的用药计划
|
||||
*/
|
||||
async findActiveMedications(userId: string): Promise<Medication[]> {
|
||||
return this.medicationModel.findAll({
|
||||
where: {
|
||||
userId,
|
||||
isActive: true,
|
||||
deleted: false,
|
||||
},
|
||||
order: [['startDate', 'ASC']],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取药物详情
|
||||
*/
|
||||
@@ -330,4 +344,4 @@ export class MedicationsService {
|
||||
`成功批量软删除了 ${affectedCount} 条 ${medication.id} 的记录`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59
src/medications/models/medication-ai-summary.model.ts
Normal file
59
src/medications/models/medication-ai-summary.model.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<string>('GLM_API_KEY');
|
||||
@@ -115,6 +129,124 @@ export class MedicationAnalysisService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当日已生成的用药总结(若存在缓存)
|
||||
*/
|
||||
async getTodaySummaryIfExists(userId: string): Promise<MedicationAiSummaryDto | null> {
|
||||
const summaryDate = dayjs().format('YYYY-MM-DD');
|
||||
return this.getCachedSummary(userId, summaryDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 总结用户当前所有开启的用药方案,并生成AI重点解读
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
async summarizeActiveMedications(userId: string): Promise<MedicationAiSummaryDto> {
|
||||
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<MedicationAiSummaryDto | null> {
|
||||
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<void> {
|
||||
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 药品信息
|
||||
|
||||
Reference in New Issue
Block a user