From f8fcc81438ec336758ff4dd70201e1bc3f2e6c33 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 21 Nov 2025 16:59:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(medications):=20=E5=A2=9E=E5=BC=BAAI?= =?UTF-8?q?=E8=8D=AF=E5=93=81=E8=AF=86=E5=88=AB=E8=B4=A8=E9=87=8F=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=92=8C=E5=A4=9A=E5=9B=BE=E7=89=87=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增图片可读性预检查机制,识别前先判断图片质量 - 设置置信度阈值为60%,低于阈值自动识别失败 - 支持多图片上传(正面、侧面、辅助图片)提高识别准确度 - 完善识别失败场景的错误分类和用户指导提示 - 新增药品有效期字段支持 - 优化AI提示词,强调安全优先原则 - 更新模型版本为 glm-4.5v 和 glm-4.5-air 数据库变更: - Medication表新增 sideImageUrl, auxiliaryImageUrl, expiryDate 字段 - DTO层同步支持新增字段的传递和更新 质量控制策略: - 图片模糊或不可读时直接返回失败 - 无法识别药品名称时主动失败 - 置信度<60%时拒绝识别,建议重新拍摄 - 宁可识别失败也不提供不准确的药品信息 --- src/medications/AI_RECOGNITION.md | 103 +++++++++++++++--- src/medications/dto/create-medication.dto.ts | 31 +++++- src/medications/dto/recognition-result.dto.ts | 23 +++- src/medications/medications.controller.ts | 25 +++-- src/medications/medications.service.ts | 18 ++- src/medications/models/medication.model.ts | 25 ++++- .../medication-recognition.service.ts | 71 +++++++++--- 7 files changed, 254 insertions(+), 42 deletions(-) diff --git a/src/medications/AI_RECOGNITION.md b/src/medications/AI_RECOGNITION.md index d44464a..f6dbbca 100644 --- a/src/medications/AI_RECOGNITION.md +++ b/src/medications/AI_RECOGNITION.md @@ -27,15 +27,24 @@ AI 药物识别功能允许用户通过上传药品照片,自动识别药品 - 提供详细的步骤描述 - 实时更新进度百分比 -### 4. 结构化输出 +### 4. 智能质量控制 + +系统会在识别前先判断图片质量: + +- **图片可读性检查**:AI 会首先判断图片是否足够清晰可读 +- **置信度评估**:识别置信度低于 60% 时会自动失败 +- **严格验证**:宁可识别失败,也不提供不准确的药品信息 +- **友好提示**:失败时会给出明确的改进建议 + +### 5. 结构化输出 识别结果包含完整的药品信息: +- 质量指标:图片可读性、识别置信度 - 基本信息:名称、剂型、剂量、服用次数、服药时间 - 适宜性分析:适合人群、不适合人群 - 成分分析:主要成分、主要用途 - 安全信息:副作用、储存建议、健康建议 -- 置信度评分 ## 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% - 正面 + 侧面 + 说明书:准确度约 90-95% -- 置信度 > 0.8:可直接使用 -- 置信度 0.5-0.8:建议人工核对 -- 置信度 < 0.5:建议重新拍照或手动输入 +- 置信度 ≥ 0.8:可直接使用 +- 置信度 0.75-0.8:建议人工核对 +- 置信度 0.6-0.75:必须人工核对 +- 置信度 < 0.6:自动识别失败,需重新拍摄 + +**安全优先原则**: + +- 宁可识别失败,也不提供不准确的药品信息 +- 当 AI 无法确认信息准确性时,会主动返回失败 +- 用户可选择手动输入以确保用药安全 ## 技术架构 diff --git a/src/medications/dto/create-medication.dto.ts b/src/medications/dto/create-medication.dto.ts index f7d870d..a46c23e 100644 --- a/src/medications/dto/create-medication.dto.ts +++ b/src/medications/dto/create-medication.dto.ts @@ -26,14 +26,32 @@ export class CreateMedicationDto { name: string; @ApiProperty({ - description: '药物照片URL', - example: 'https://cdn.example.com/medications/med_001.jpg', + description: '药物正面照片URL', + example: 'https://cdn.example.com/medications/front_001.jpg', required: false, }) @IsString() @IsOptional() 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({ description: '药物剂型', enum: MedicationFormEnum, @@ -98,6 +116,15 @@ export class CreateMedicationDto { @IsOptional() endDate?: string; + @ApiProperty({ + description: '药品有效期,ISO 8601 格式(可选)', + example: '2026-12-31T23:59:59.999Z', + required: false, + }) + @IsDateString() + @IsOptional() + expiryDate?: string; + @ApiProperty({ description: '备注信息', example: '饭后服用', required: false }) @IsString() @IsOptional() diff --git a/src/medications/dto/recognition-result.dto.ts b/src/medications/dto/recognition-result.dto.ts index 17c6de6..df0dc2c 100644 --- a/src/medications/dto/recognition-result.dto.ts +++ b/src/medications/dto/recognition-result.dto.ts @@ -6,15 +6,36 @@ import { MedicationFormEnum } from '../enums/medication-form.enum'; * 包含创建药物所需的所有字段 + AI分析结果 */ export class RecognitionResultDto { + @ApiProperty({ + description: '图片是否清晰可读(AI判断)', + example: true, + required: false, + }) + isReadable?: boolean; + @ApiProperty({ description: '药品名称', example: '阿莫西林胶囊' }) name: string; @ApiProperty({ - description: '药品照片URL(使用正面图片)', + description: '药品正面照片URL', example: 'https://cdn.example.com/medications/front_001.jpg', }) 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({ description: '药物剂型', enum: MedicationFormEnum, diff --git a/src/medications/medications.controller.ts b/src/medications/medications.controller.ts index ebe4c56..4fa387e 100644 --- a/src/medications/medications.controller.ts +++ b/src/medications/medications.controller.ts @@ -426,6 +426,8 @@ export class MedicationsController { const createDto: CreateMedicationDto = { name: adjustments?.name || status.result.name, photoUrl: adjustments?.photoUrl || status.result.photoUrl, + sideImageUrl: adjustments?.sideImageUrl || status.result.sideImageUrl, + auxiliaryImageUrl: adjustments?.auxiliaryImageUrl || status.result.auxiliaryImageUrl, form: adjustments?.form || status.result.form, dosageValue: adjustments?.dosageValue || status.result.dosageValue, dosageUnit: adjustments?.dosageUnit || status.result.dosageUnit, @@ -436,6 +438,7 @@ export class MedicationsController { startDate: adjustments?.startDate || new Date().toISOString(), endDate: adjustments?.endDate, + expiryDate: adjustments?.expiryDate, note: adjustments?.note, isActive: adjustments?.isActive !== undefined ? adjustments.isActive : true, }; @@ -446,16 +449,22 @@ export class MedicationsController { createDto, ); - // 4. 保存AI分析结果到药物记录 + // 4. 保存完整的AI分析结果到药物记录 + // 确保保存所有AI分析字段,与 getAiAnalysisV2 保持一致 const aiAnalysis = { - suitableFor: status.result.suitableFor, - unsuitableFor: status.result.unsuitableFor, - mainIngredients: status.result.mainIngredients, - mainUsage: status.result.mainUsage, - sideEffects: status.result.sideEffects, - storageAdvice: status.result.storageAdvice, - healthAdvice: status.result.healthAdvice, + suitableFor: status.result.suitableFor || [], + unsuitableFor: status.result.unsuitableFor || [], + mainIngredients: status.result.mainIngredients || [], + mainUsage: status.result.mainUsage || '', + sideEffects: status.result.sideEffects || [], + storageAdvice: status.result.storageAdvice || [], + healthAdvice: status.result.healthAdvice || [], }; + + this.logger.log( + `保存AI分析结果 - 药物ID: ${medication.id}, 数据: ${JSON.stringify(aiAnalysis)}`, + ); + await this.medicationsService.update(medication.id, user.sub, { aiAnalysis: JSON.stringify(aiAnalysis), } as any); diff --git a/src/medications/medications.service.ts b/src/medications/medications.service.ts index 4de26b5..06102a3 100644 --- a/src/medications/medications.service.ts +++ b/src/medications/medications.service.ts @@ -45,6 +45,8 @@ export class MedicationsService { userId, name: createDto.name, photoUrl: createDto.photoUrl, + sideImageUrl: createDto.sideImageUrl, + auxiliaryImageUrl: createDto.auxiliaryImageUrl, form: createDto.form, dosageValue: createDto.dosageValue, dosageUnit: createDto.dosageUnit, @@ -53,6 +55,7 @@ export class MedicationsService { repeatPattern: createDto.repeatPattern, startDate: new Date(createDto.startDate), endDate: createDto.endDate ? new Date(createDto.endDate) : null, + expiryDate: createDto.expiryDate ? new Date(createDto.expiryDate) : null, note: createDto.note, isActive: createDto.isActive !== undefined ? createDto.isActive : true, deleted: false, @@ -133,6 +136,12 @@ export class MedicationsService { if (updateDto.photoUrl !== undefined) { medication.photoUrl = updateDto.photoUrl; } + if (updateDto.sideImageUrl !== undefined) { + medication.sideImageUrl = updateDto.sideImageUrl; + } + if (updateDto.auxiliaryImageUrl !== undefined) { + medication.auxiliaryImageUrl = updateDto.auxiliaryImageUrl; + } if (updateDto.form !== undefined) { medication.form = updateDto.form; } @@ -155,7 +164,10 @@ export class MedicationsService { medication.startDate = new Date(updateDto.startDate); } 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) { medication.note = updateDto.note; @@ -163,6 +175,10 @@ export class MedicationsService { if (updateDto.isActive !== undefined) { medication.isActive = updateDto.isActive; } + // 支持更新 AI 分析结果 + if ((updateDto as any).aiAnalysis !== undefined) { + medication.aiAnalysis = (updateDto as any).aiAnalysis; + } await medication.save(); diff --git a/src/medications/models/medication.model.ts b/src/medications/models/medication.model.ts index a077859..b9223e5 100644 --- a/src/medications/models/medication.model.ts +++ b/src/medications/models/medication.model.ts @@ -36,10 +36,24 @@ export class Medication extends Model { @Column({ type: DataType.STRING(255), allowNull: true, - comment: '药物照片URL', + comment: '药物正面照片URL', }) 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({ type: DataType.STRING(20), allowNull: false, @@ -95,7 +109,14 @@ export class Medication extends Model { allowNull: true, comment: '结束日期(UTC时间)', }) - declare endDate: Date; + declare endDate: Date | null; + + @Column({ + type: DataType.DATE, + allowNull: true, + comment: '药品有效期(UTC时间)', + }) + declare expiryDate: Date | null; @Column({ type: DataType.TEXT, diff --git a/src/medications/services/medication-recognition.service.ts b/src/medications/services/medication-recognition.service.ts index 57c968c..9de943f 100644 --- a/src/medications/services/medication-recognition.service.ts +++ b/src/medications/services/medication-recognition.service.ts @@ -10,7 +10,6 @@ import { RecognitionStatusEnum, RECOGNITION_STATUS_DESCRIPTIONS, } from '../enums/recognition-status.enum'; -import { MedicationFormEnum } from '../enums/medication-form.enum'; /** * 药物AI识别服务 @@ -39,9 +38,9 @@ export class MedicationRecognitionService { }); this.visionModel = - this.configService.get('GLM_VISION_MODEL') || 'glm-4v-plus'; + this.configService.get('GLM_VISION_MODEL') || 'glm-4.5v'; this.textModel = - this.configService.get('GLM_MODEL') || 'glm-4-flash'; + this.configService.get('GLM_MODEL') || 'glm-4.5-air'; } /** @@ -167,12 +166,16 @@ export class MedicationRecognitionService { ); const effectsInfo = await this.analyzeEffects(productInfo); - // 合并所有结果 + // 合并所有结果,透传所有原始图片URL(避免被AI模型修改) const finalResult = { ...productInfo, ...suitabilityInfo, ...ingredientsInfo, ...effectsInfo, + // 强制使用任务记录中存储的原始图片URL,覆盖AI可能返回的不正确链接 + photoUrl: task.frontImageUrl, + sideImageUrl: task.sideImageUrl, + auxiliaryImageUrl: task.auxiliaryImageUrl, } as RecognitionResultDto; // 完成识别 @@ -224,7 +227,31 @@ export class MedicationRecognitionService { } 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; } @@ -324,20 +351,29 @@ export class MedicationRecognitionService { private buildProductRecognitionPrompt(): string { return `你是一位拥有20年从业经验的资深药剂师,请根据提供的药品图片(包括正面、侧面和可能的辅助面)进行详细分析。 -**分析要求**: +**重要前提条件 - 图片可读性判断**: +⚠️ 在进行任何识别之前,你必须首先判断图片是否足够清晰可读: +1. 检查图片是否模糊、过曝、欠曝或有严重反光 +2. 检查药品名称、规格等关键信息是否清晰可见 +3. 检查文字是否完整、无遮挡 +4. 如果图片质量不佳,无法清晰辨认关键信息,必须设置 isReadable 为 false + +**只有在图片清晰可读的情况下才能继续分析**: 1. 仔细观察药品包装、说明书上的所有信息 2. 识别药品的完整名称(通用名和商品名) 3. 确定药物剂型(片剂/胶囊/注射剂等) 4. 提取规格剂量信息 5. 推荐合理的服用次数和时间 -**置信度评估标准**: +**置信度评估标准(仅在图片可读时评估)**: - 如果图片清晰且信息完整,置信度应 >= 0.8 -- 如果部分信息不清晰但可推断,置信度 0.5-0.8 -- 如果无法准确识别,置信度 < 0.5,name返回"无法识别" +- 如果部分信息不清晰但大部分可推断,置信度 0.6-0.8 +- 如果关键信息缺失或模糊不清,置信度 < 0.6,name返回"无法识别" +- 置信度评估必须严格基于实际可见信息,不能猜测或臆断 **返回严格的JSON格式**(不要包含任何markdown标记): { + "isReadable": true或false(图片是否足够清晰可读), "name": "药品完整名称", "photoUrl": "使用正面图片URL", "form": "剂型(tablet/capsule/injection/drops/syrup/ointment/powder/granules)", @@ -348,12 +384,17 @@ export class MedicationRecognitionService { "confidence": 识别置信度(0-1的小数) } -**重要**: -- dosageValue 和 timesPerDay 必须是数字类型,不要加引号 -- confidence 必须是 0-1 之间的小数 -- medicationTimes 必须是 HH:mm 格式的时间数组 -- form 必须是枚举值之一 -- 如果无法识别,name返回"无法识别",其他字段返回合理的默认值`; +**关键规则(必须遵守)**: +1. isReadable 是最重要的字段,如果为 false,其他识别结果将被忽略 +2. 当图片模糊、反光、文字不清晰时,必须设置 isReadable 为 false +3. 只有在确实能看清并理解图片内容时,才能设置 isReadable 为 true +4. confidence 必须反映真实的识别把握程度,不能虚高 +5. 如果 isReadable 为 false,name 必须返回"无法识别",confidence 设为 0 +6. dosageValue 和 timesPerDay 必须是数字类型,不要加引号 +7. medicationTimes 必须是 HH:mm 格式的时间数组 +8. form 必须是枚举值之一 + +**宁可识别失败,也不要提供不准确的药品信息。用药安全高于一切!**`; } /**