feat(medications): 增强AI药品识别质量控制和多图片支持

- 新增图片可读性预检查机制,识别前先判断图片质量
- 设置置信度阈值为60%,低于阈值自动识别失败
- 支持多图片上传(正面、侧面、辅助图片)提高识别准确度
- 完善识别失败场景的错误分类和用户指导提示
- 新增药品有效期字段支持
- 优化AI提示词,强调安全优先原则
- 更新模型版本为 glm-4.5v 和 glm-4.5-air

数据库变更:
- Medication表新增 sideImageUrl, auxiliaryImageUrl, expiryDate 字段
- DTO层同步支持新增字段的传递和更新

质量控制策略:
- 图片模糊或不可读时直接返回失败
- 无法识别药品名称时主动失败
- 置信度<60%时拒绝识别,建议重新拍摄
- 宁可识别失败也不提供不准确的药品信息
This commit is contained in:
richarjiang
2025-11-21 16:59:36 +08:00
parent a17fe0b965
commit f8fcc81438
7 changed files with 254 additions and 42 deletions

View File

@@ -27,15 +27,24 @@ AI 药物识别功能允许用户通过上传药品照片,自动识别药品
- 提供详细的步骤描述 - 提供详细的步骤描述
- 实时更新进度百分比 - 实时更新进度百分比
### 4. 结构化输出 ### 4. 智能质量控制
系统会在识别前先判断图片质量:
- **图片可读性检查**AI 会首先判断图片是否足够清晰可读
- **置信度评估**:识别置信度低于 60% 时会自动失败
- **严格验证**:宁可识别失败,也不提供不准确的药品信息
- **友好提示**:失败时会给出明确的改进建议
### 5. 结构化输出
识别结果包含完整的药品信息: 识别结果包含完整的药品信息:
- 质量指标:图片可读性、识别置信度
- 基本信息:名称、剂型、剂量、服用次数、服药时间 - 基本信息:名称、剂型、剂量、服用次数、服药时间
- 适宜性分析:适合人群、不适合人群 - 适宜性分析:适合人群、不适合人群
- 成分分析:主要成分、主要用途 - 成分分析:主要成分、主要用途
- 安全信息:副作用、储存建议、健康建议 - 安全信息:副作用、储存建议、健康建议
- 置信度评分
## API 接口 ## API 接口
@@ -400,15 +409,67 @@ async function recognizeAndCreateMedication() {
### 识别失败处理 ### 识别失败处理
当识别状态为 `failed` 时: 当识别状态为 `failed`,系统会提供明确的失败原因
1. 显示友好的错误提示 #### 失败原因分类
2. 提供重试选项
3. 建议用户 1. **图片质量问题**
- 拍摄更清晰的照片
- 确保光线充足 - 错误信息:`图片模糊或光线不足,无法清晰识别药品信息`
- 药品名称完整可见 - 用户建议:
- 或选择手动输入 - 在光线充足的环境下重新拍摄
- 确保相机对焦清晰
- 避免手抖造成的模糊
- 避免反光和阴影
2. **药品信息不可见**
- 错误信息:`无法从图片中识别出药品名称`
- 用户建议:
- 确保药品名称完整出现在画面中
- 调整拍摄角度,避免遮挡
- 拍摄药品包装正面和侧面
- 如有说明书可一并拍摄
3. **识别置信度过低**
- 错误信息:`识别置信度过低 (XX%)。建议重新拍摄更清晰的照片`
- 用户建议:
- 拍摄更清晰的照片
- 确保药品规格、剂量等信息清晰可见
- 可选择手动输入药品信息
#### 前端处理建议
```typescript
if (status === "failed") {
// 根据错误信息提供针对性的指导
if (errorMessage.includes("图片模糊")) {
showTip("拍照小贴士", [
"在明亮的环境下拍摄",
"保持相机稳定,避免抖动",
"等待相机对焦后再拍摄",
]);
} else if (errorMessage.includes("无法识别出药品名称")) {
showTip("拍照小贴士", [
"确保药品名称完整可见",
"拍摄药品包装的正面",
"避免手指或其他物体遮挡",
]);
} else if (errorMessage.includes("置信度过低")) {
showTip("识别建议", [
"重新拍摄更清晰的照片",
"同时拍摄说明书可提高准确度",
"或选择手动输入药品信息",
]);
}
// 提供重试和手动输入选项
showActions([
{ text: "重新拍摄", action: retryPhoto },
{ text: "手动输入", action: manualInput },
]);
}
```
## 性能优化建议 ## 性能优化建议
@@ -459,11 +520,27 @@ async function recognizeAndCreateMedication() {
### 识别准确度 ### 识别准确度
**质量控制标准**
- 图片必须清晰可读isReadable = true
- 识别置信度必须 ≥ 60% 才能通过
- 置信度 60-75%:会给出警告,建议用户核对
- 置信度 ≥ 75%:可信度较高
**推荐使用标准**
- 正面 + 侧面:准确度约 85-90% - 正面 + 侧面:准确度约 85-90%
- 正面 + 侧面 + 说明书:准确度约 90-95% - 正面 + 侧面 + 说明书:准确度约 90-95%
- 置信度 > 0.8:可直接使用 - 置信度 0.8:可直接使用
- 置信度 0.5-0.8:建议人工核对 - 置信度 0.75-0.8:建议人工核对
- 置信度 < 0.5建议重新拍照或手动输入 - 置信度 0.6-0.75:必须人工核对
- 置信度 < 0.6自动识别失败需重新拍摄
**安全优先原则**
- 宁可识别失败也不提供不准确的药品信息
- AI 无法确认信息准确性时会主动返回失败
- 用户可选择手动输入以确保用药安全
## 技术架构 ## 技术架构

View File

@@ -26,14 +26,32 @@ export class CreateMedicationDto {
name: string; name: string;
@ApiProperty({ @ApiProperty({
description: '药物照片URL', description: '药物正面照片URL',
example: 'https://cdn.example.com/medications/med_001.jpg', example: 'https://cdn.example.com/medications/front_001.jpg',
required: false, required: false,
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
photoUrl?: string; photoUrl?: string;
@ApiProperty({
description: '药物侧面照片URL',
example: 'https://cdn.example.com/medications/side_001.jpg',
required: false,
})
@IsString()
@IsOptional()
sideImageUrl?: string;
@ApiProperty({
description: '药物辅助照片URL可选的第三张图片',
example: 'https://cdn.example.com/medications/auxiliary_001.jpg',
required: false,
})
@IsString()
@IsOptional()
auxiliaryImageUrl?: string;
@ApiProperty({ @ApiProperty({
description: '药物剂型', description: '药物剂型',
enum: MedicationFormEnum, enum: MedicationFormEnum,
@@ -98,6 +116,15 @@ export class CreateMedicationDto {
@IsOptional() @IsOptional()
endDate?: string; endDate?: string;
@ApiProperty({
description: '药品有效期ISO 8601 格式(可选)',
example: '2026-12-31T23:59:59.999Z',
required: false,
})
@IsDateString()
@IsOptional()
expiryDate?: string;
@ApiProperty({ description: '备注信息', example: '饭后服用', required: false }) @ApiProperty({ description: '备注信息', example: '饭后服用', required: false })
@IsString() @IsString()
@IsOptional() @IsOptional()

View File

@@ -6,15 +6,36 @@ import { MedicationFormEnum } from '../enums/medication-form.enum';
* 包含创建药物所需的所有字段 + AI分析结果 * 包含创建药物所需的所有字段 + AI分析结果
*/ */
export class RecognitionResultDto { export class RecognitionResultDto {
@ApiProperty({
description: '图片是否清晰可读AI判断',
example: true,
required: false,
})
isReadable?: boolean;
@ApiProperty({ description: '药品名称', example: '阿莫西林胶囊' }) @ApiProperty({ description: '药品名称', example: '阿莫西林胶囊' })
name: string; name: string;
@ApiProperty({ @ApiProperty({
description: '药品照片URL(使用正面图片)', description: '药品正面照片URL',
example: 'https://cdn.example.com/medications/front_001.jpg', example: 'https://cdn.example.com/medications/front_001.jpg',
}) })
photoUrl: string; photoUrl: string;
@ApiProperty({
description: '药品侧面照片URL',
example: 'https://cdn.example.com/medications/side_001.jpg',
required: false,
})
sideImageUrl?: string;
@ApiProperty({
description: '药品辅助照片URL',
example: 'https://cdn.example.com/medications/auxiliary_001.jpg',
required: false,
})
auxiliaryImageUrl?: string;
@ApiProperty({ @ApiProperty({
description: '药物剂型', description: '药物剂型',
enum: MedicationFormEnum, enum: MedicationFormEnum,

View File

@@ -426,6 +426,8 @@ export class MedicationsController {
const createDto: CreateMedicationDto = { const createDto: CreateMedicationDto = {
name: adjustments?.name || status.result.name, name: adjustments?.name || status.result.name,
photoUrl: adjustments?.photoUrl || status.result.photoUrl, photoUrl: adjustments?.photoUrl || status.result.photoUrl,
sideImageUrl: adjustments?.sideImageUrl || status.result.sideImageUrl,
auxiliaryImageUrl: adjustments?.auxiliaryImageUrl || status.result.auxiliaryImageUrl,
form: adjustments?.form || status.result.form, form: adjustments?.form || status.result.form,
dosageValue: adjustments?.dosageValue || status.result.dosageValue, dosageValue: adjustments?.dosageValue || status.result.dosageValue,
dosageUnit: adjustments?.dosageUnit || status.result.dosageUnit, dosageUnit: adjustments?.dosageUnit || status.result.dosageUnit,
@@ -436,6 +438,7 @@ export class MedicationsController {
startDate: startDate:
adjustments?.startDate || new Date().toISOString(), adjustments?.startDate || new Date().toISOString(),
endDate: adjustments?.endDate, endDate: adjustments?.endDate,
expiryDate: adjustments?.expiryDate,
note: adjustments?.note, note: adjustments?.note,
isActive: adjustments?.isActive !== undefined ? adjustments.isActive : true, isActive: adjustments?.isActive !== undefined ? adjustments.isActive : true,
}; };
@@ -446,16 +449,22 @@ export class MedicationsController {
createDto, createDto,
); );
// 4. 保存AI分析结果到药物记录 // 4. 保存完整的AI分析结果到药物记录
// 确保保存所有AI分析字段与 getAiAnalysisV2 保持一致
const aiAnalysis = { const aiAnalysis = {
suitableFor: status.result.suitableFor, suitableFor: status.result.suitableFor || [],
unsuitableFor: status.result.unsuitableFor, unsuitableFor: status.result.unsuitableFor || [],
mainIngredients: status.result.mainIngredients, mainIngredients: status.result.mainIngredients || [],
mainUsage: status.result.mainUsage, mainUsage: status.result.mainUsage || '',
sideEffects: status.result.sideEffects, sideEffects: status.result.sideEffects || [],
storageAdvice: status.result.storageAdvice, storageAdvice: status.result.storageAdvice || [],
healthAdvice: status.result.healthAdvice, healthAdvice: status.result.healthAdvice || [],
}; };
this.logger.log(
`保存AI分析结果 - 药物ID: ${medication.id}, 数据: ${JSON.stringify(aiAnalysis)}`,
);
await this.medicationsService.update(medication.id, user.sub, { await this.medicationsService.update(medication.id, user.sub, {
aiAnalysis: JSON.stringify(aiAnalysis), aiAnalysis: JSON.stringify(aiAnalysis),
} as any); } as any);

View File

@@ -45,6 +45,8 @@ export class MedicationsService {
userId, userId,
name: createDto.name, name: createDto.name,
photoUrl: createDto.photoUrl, photoUrl: createDto.photoUrl,
sideImageUrl: createDto.sideImageUrl,
auxiliaryImageUrl: createDto.auxiliaryImageUrl,
form: createDto.form, form: createDto.form,
dosageValue: createDto.dosageValue, dosageValue: createDto.dosageValue,
dosageUnit: createDto.dosageUnit, dosageUnit: createDto.dosageUnit,
@@ -53,6 +55,7 @@ export class MedicationsService {
repeatPattern: createDto.repeatPattern, repeatPattern: createDto.repeatPattern,
startDate: new Date(createDto.startDate), startDate: new Date(createDto.startDate),
endDate: createDto.endDate ? new Date(createDto.endDate) : null, endDate: createDto.endDate ? new Date(createDto.endDate) : null,
expiryDate: createDto.expiryDate ? new Date(createDto.expiryDate) : null,
note: createDto.note, note: createDto.note,
isActive: createDto.isActive !== undefined ? createDto.isActive : true, isActive: createDto.isActive !== undefined ? createDto.isActive : true,
deleted: false, deleted: false,
@@ -133,6 +136,12 @@ export class MedicationsService {
if (updateDto.photoUrl !== undefined) { if (updateDto.photoUrl !== undefined) {
medication.photoUrl = updateDto.photoUrl; medication.photoUrl = updateDto.photoUrl;
} }
if (updateDto.sideImageUrl !== undefined) {
medication.sideImageUrl = updateDto.sideImageUrl;
}
if (updateDto.auxiliaryImageUrl !== undefined) {
medication.auxiliaryImageUrl = updateDto.auxiliaryImageUrl;
}
if (updateDto.form !== undefined) { if (updateDto.form !== undefined) {
medication.form = updateDto.form; medication.form = updateDto.form;
} }
@@ -155,7 +164,10 @@ export class MedicationsService {
medication.startDate = new Date(updateDto.startDate); medication.startDate = new Date(updateDto.startDate);
} }
if (updateDto.endDate !== undefined) { if (updateDto.endDate !== undefined) {
medication.endDate = new Date(updateDto.endDate); medication.endDate = updateDto.endDate ? new Date(updateDto.endDate) : null;
}
if (updateDto.expiryDate !== undefined) {
medication.expiryDate = updateDto.expiryDate ? new Date(updateDto.expiryDate) : null;
} }
if (updateDto.note !== undefined) { if (updateDto.note !== undefined) {
medication.note = updateDto.note; medication.note = updateDto.note;
@@ -163,6 +175,10 @@ export class MedicationsService {
if (updateDto.isActive !== undefined) { if (updateDto.isActive !== undefined) {
medication.isActive = updateDto.isActive; medication.isActive = updateDto.isActive;
} }
// 支持更新 AI 分析结果
if ((updateDto as any).aiAnalysis !== undefined) {
medication.aiAnalysis = (updateDto as any).aiAnalysis;
}
await medication.save(); await medication.save();

View File

@@ -36,10 +36,24 @@ export class Medication extends Model {
@Column({ @Column({
type: DataType.STRING(255), type: DataType.STRING(255),
allowNull: true, allowNull: true,
comment: '药物照片URL', comment: '药物正面照片URL',
}) })
declare photoUrl: string; declare photoUrl: string;
@Column({
type: DataType.STRING(255),
allowNull: true,
comment: '药物侧面照片URL',
})
declare sideImageUrl: string;
@Column({
type: DataType.STRING(255),
allowNull: true,
comment: '药物辅助照片URL可选的第三张图片',
})
declare auxiliaryImageUrl: string;
@Column({ @Column({
type: DataType.STRING(20), type: DataType.STRING(20),
allowNull: false, allowNull: false,
@@ -95,7 +109,14 @@ export class Medication extends Model {
allowNull: true, allowNull: true,
comment: '结束日期UTC时间', comment: '结束日期UTC时间',
}) })
declare endDate: Date; declare endDate: Date | null;
@Column({
type: DataType.DATE,
allowNull: true,
comment: '药品有效期UTC时间',
})
declare expiryDate: Date | null;
@Column({ @Column({
type: DataType.TEXT, type: DataType.TEXT,

View File

@@ -10,7 +10,6 @@ import {
RecognitionStatusEnum, RecognitionStatusEnum,
RECOGNITION_STATUS_DESCRIPTIONS, RECOGNITION_STATUS_DESCRIPTIONS,
} from '../enums/recognition-status.enum'; } from '../enums/recognition-status.enum';
import { MedicationFormEnum } from '../enums/medication-form.enum';
/** /**
* 药物AI识别服务 * 药物AI识别服务
@@ -39,9 +38,9 @@ export class MedicationRecognitionService {
}); });
this.visionModel = this.visionModel =
this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4v-plus'; this.configService.get<string>('GLM_VISION_MODEL') || 'glm-4.5v';
this.textModel = this.textModel =
this.configService.get<string>('GLM_MODEL') || 'glm-4-flash'; this.configService.get<string>('GLM_MODEL') || 'glm-4.5-air';
} }
/** /**
@@ -167,12 +166,16 @@ export class MedicationRecognitionService {
); );
const effectsInfo = await this.analyzeEffects(productInfo); const effectsInfo = await this.analyzeEffects(productInfo);
// 合并所有结果 // 合并所有结果透传所有原始图片URL避免被AI模型修改
const finalResult = { const finalResult = {
...productInfo, ...productInfo,
...suitabilityInfo, ...suitabilityInfo,
...ingredientsInfo, ...ingredientsInfo,
...effectsInfo, ...effectsInfo,
// 强制使用任务记录中存储的原始图片URL覆盖AI可能返回的不正确链接
photoUrl: task.frontImageUrl,
sideImageUrl: task.sideImageUrl,
auxiliaryImageUrl: task.auxiliaryImageUrl,
} as RecognitionResultDto; } as RecognitionResultDto;
// 完成识别 // 完成识别
@@ -224,7 +227,31 @@ export class MedicationRecognitionService {
} }
const parsed = this.parseJsonResponse(content); const parsed = this.parseJsonResponse(content);
this.logger.log(`药品基本信息识别完成: ${parsed.name}, 置信度: ${parsed.confidence}`);
// 严格的置信度和可读性验证
const confidence = parsed.confidence || 0;
const isReadable = parsed.isReadable !== false; // 默认为 true 保持向后兼容
this.logger.log(
`药品识别结果: ${parsed.name}, 置信度: ${confidence}, 可读性: ${isReadable}`,
);
// 如果模型明确表示看不清或置信度过低,则失败
if (!isReadable) {
throw new Error('图片模糊或光线不足,无法清晰识别药品信息。请重新拍摄更清晰的照片。');
}
if (parsed.name === '无法识别' || parsed.name === '未知' || !parsed.name) {
throw new Error('无法从图片中识别出药品名称。请确保药品名称清晰可见,或选择手动输入。');
}
// 置信度阈值检查:低于 0.6 视为不可靠
if (confidence < 0.6) {
throw new Error(
`识别置信度过低 (${(confidence * 100).toFixed(0)}%)。建议重新拍摄更清晰的照片,确保药品名称、规格等信息清晰可见。`,
);
}
return parsed; return parsed;
} }
@@ -324,20 +351,29 @@ export class MedicationRecognitionService {
private buildProductRecognitionPrompt(): string { private buildProductRecognitionPrompt(): string {
return `你是一位拥有20年从业经验的资深药剂师请根据提供的药品图片包括正面、侧面和可能的辅助面进行详细分析。 return `你是一位拥有20年从业经验的资深药剂师请根据提供的药品图片包括正面、侧面和可能的辅助面进行详细分析。
**分析要求** **重要前提条件 - 图片可读性判断**
⚠️ 在进行任何识别之前,你必须首先判断图片是否足够清晰可读:
1. 检查图片是否模糊、过曝、欠曝或有严重反光
2. 检查药品名称、规格等关键信息是否清晰可见
3. 检查文字是否完整、无遮挡
4. 如果图片质量不佳,无法清晰辨认关键信息,必须设置 isReadable 为 false
**只有在图片清晰可读的情况下才能继续分析**
1. 仔细观察药品包装、说明书上的所有信息 1. 仔细观察药品包装、说明书上的所有信息
2. 识别药品的完整名称(通用名和商品名) 2. 识别药品的完整名称(通用名和商品名)
3. 确定药物剂型(片剂/胶囊/注射剂等) 3. 确定药物剂型(片剂/胶囊/注射剂等)
4. 提取规格剂量信息 4. 提取规格剂量信息
5. 推荐合理的服用次数和时间 5. 推荐合理的服用次数和时间
**置信度评估标准** **置信度评估标准(仅在图片可读时评估)**
- 如果图片清晰且信息完整,置信度应 >= 0.8 - 如果图片清晰且信息完整,置信度应 >= 0.8
- 如果部分信息不清晰但可推断,置信度 0.5-0.8 - 如果部分信息不清晰但大部分可推断,置信度 0.6-0.8
- 如果无法准确识别,置信度 < 0.5name返回"无法识别" - 如果关键信息缺失或模糊不清,置信度 < 0.6name返回"无法识别"
- 置信度评估必须严格基于实际可见信息,不能猜测或臆断
**返回严格的JSON格式**不要包含任何markdown标记 **返回严格的JSON格式**不要包含任何markdown标记
{ {
"isReadable": true或false图片是否足够清晰可读,
"name": "药品完整名称", "name": "药品完整名称",
"photoUrl": "使用正面图片URL", "photoUrl": "使用正面图片URL",
"form": "剂型(tablet/capsule/injection/drops/syrup/ointment/powder/granules)", "form": "剂型(tablet/capsule/injection/drops/syrup/ointment/powder/granules)",
@@ -348,12 +384,17 @@ export class MedicationRecognitionService {
"confidence": 识别置信度(0-1的小数) "confidence": 识别置信度(0-1的小数)
} }
**重要** **关键规则(必须遵守)**
- dosageValue 和 timesPerDay 必须是数字类型,不要加引号 1. isReadable 是最重要的字段,如果为 false其他识别结果将被忽略
- confidence 必须是 0-1 之间的小数 2. 当图片模糊、反光、文字不清晰时,必须设置 isReadable 为 false
- medicationTimes 必须是 HH:mm 格式的时间数组 3. 只有在确实能看清并理解图片内容时,才能设置 isReadable 为 true
- form 必须是枚举值之一 4. confidence 必须反映真实的识别把握程度,不能虚高
- 如果无法识别name返回"无法识别"其他字段返回合理的默认值`; 5. 如果 isReadable 为 falsename 必须返回"无法识别"confidence 设为 0
6. dosageValue 和 timesPerDay 必须是数字类型,不要加引号
7. medicationTimes 必须是 HH:mm 格式的时间数组
8. form 必须是枚举值之一
**宁可识别失败,也不要提供不准确的药品信息。用药安全高于一切!**`;
} }
/** /**