feat(medications): 添加用药AI总结功能,支持生成用户用药计划的重点解读

This commit is contained in:
richarjiang
2025-12-01 11:21:57 +08:00
parent ae41a2b643
commit 7ce51409af
7 changed files with 402 additions and 3 deletions

View 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 存储生成的重点解读文本。

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

View File

@@ -27,6 +27,7 @@ import { MedicationRecognitionService } from './services/medication-recognition.
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { RepeatPatternEnum } from './enums/repeat-pattern.enum'; import { RepeatPatternEnum } from './enums/repeat-pattern.enum';
import { RecognitionStatusEnum } from './enums/recognition-status.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, '查询成功'); 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') @Get(':id')
@ApiOperation({ summary: '获取药物详情' }) @ApiOperation({ summary: '获取药物详情' })
@ApiResponse({ status: 200, description: '查询成功' }) @ApiResponse({ status: 200, description: '查询成功' })
@@ -402,4 +457,4 @@ export class MedicationsController {
); );
} }
} }
} }

View File

@@ -7,6 +7,7 @@ import { ConfigModule } from '@nestjs/config';
import { Medication } from './models/medication.model'; import { Medication } from './models/medication.model';
import { MedicationRecord } from './models/medication-record.model'; import { MedicationRecord } from './models/medication-record.model';
import { MedicationRecognitionTask } from './models/medication-recognition-task.model'; import { MedicationRecognitionTask } from './models/medication-recognition-task.model';
import { MedicationAiSummary } from './models/medication-ai-summary.model';
// Controllers // Controllers
import { MedicationsController } from './medications.controller'; import { MedicationsController } from './medications.controller';
@@ -38,6 +39,7 @@ import { UsersModule } from '../users/users.module';
Medication, Medication,
MedicationRecord, MedicationRecord,
MedicationRecognitionTask, MedicationRecognitionTask,
MedicationAiSummary,
]), ]),
ScheduleModule.forRoot(), // 启用定时任务 ScheduleModule.forRoot(), // 启用定时任务
PushNotificationsModule, // 推送通知功能 PushNotificationsModule, // 推送通知功能
@@ -64,4 +66,4 @@ import { UsersModule } from '../users/users.module';
MedicationStatsService, MedicationStatsService,
], ],
}) })
export class MedicationsModule {} export class MedicationsModule {}

View File

@@ -93,6 +93,20 @@ export class MedicationsService {
return { rows, total: count }; return { rows, total: count };
} }
/**
* 获取用户所有正在进行的用药计划
*/
async findActiveMedications(userId: string): Promise<Medication[]> {
return this.medicationModel.findAll({
where: {
userId,
isActive: true,
deleted: false,
},
order: [['startDate', 'ASC']],
});
}
/** /**
* 根据ID获取药物详情 * 根据ID获取药物详情
*/ */
@@ -330,4 +344,4 @@ export class MedicationsService {
`成功批量软删除了 ${affectedCount}${medication.id} 的记录`, `成功批量软删除了 ${affectedCount}${medication.id} 的记录`,
); );
} }
} }

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

View File

@@ -3,10 +3,20 @@ import { ConfigService } from '@nestjs/config';
import { InjectModel } from '@nestjs/sequelize'; import { InjectModel } from '@nestjs/sequelize';
import { OpenAI } from 'openai'; import { OpenAI } from 'openai';
import { Readable } from 'stream'; import { Readable } from 'stream';
import * as dayjs from 'dayjs';
import { Op } from 'sequelize';
import { UsersService } from '../../users/users.service'; import { UsersService } from '../../users/users.service';
import { MedicationsService } from '../medications.service'; import { MedicationsService } from '../medications.service';
import { Medication } from '../models/medication.model'; 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 { AiAnalysisResultDto } from '../dto/ai-analysis-result.dto';
import {
MedicationAiSummaryDto,
MedicationPlanItemDto,
} from '../dto/medication-ai-summary.dto';
import { v4 as uuidv4 } from 'uuid';
interface LanguageConfig { interface LanguageConfig {
label: string; label: string;
@@ -32,6 +42,10 @@ export class MedicationAnalysisService {
private readonly usersService: UsersService, private readonly usersService: UsersService,
@InjectModel(Medication) @InjectModel(Medication)
private readonly medicationModel: typeof Medication, private readonly medicationModel: typeof Medication,
@InjectModel(MedicationRecord)
private readonly recordModel: typeof MedicationRecord,
@InjectModel(MedicationAiSummary)
private readonly summaryModel: typeof MedicationAiSummary,
) { ) {
// GLM-4.5V Configuration // GLM-4.5V Configuration
const glmApiKey = this.configService.get<string>('GLM_API_KEY'); 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 分析提示 * @param prompt 分析提示
@@ -352,6 +484,82 @@ export class MedicationAnalysisService {
return readable; 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 药品信息 * @param medication 药品信息