using Health.Application.Reports; using Health.Application.Notifications; using Health.Infrastructure.AI; using Microsoft.Extensions.Logging; namespace Health.Infrastructure.Reports; public sealed class ReportAnalysisService( IReportRepository reports, VisionClient vision, DeepSeekClient llm, IUserNotificationProducer notifications, ILogger logger) : IReportAnalysisService { private readonly IReportRepository _reports = reports; private readonly VisionClient _vision = vision; private readonly DeepSeekClient _llm = llm; private readonly IUserNotificationProducer _notifications = notifications; private readonly ILogger _logger = logger; public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct) { var report = await _reports.GetByIdAsync(job.ReportId, ct); if (report == null) return; try { var vlmJson = await ExtractIndicatorsAsync(job.FilePath, ct); var isReport = vlmJson.TryGetProperty("isReport", out var ir) && ir.GetBoolean(); var reason = vlmJson.TryGetProperty("reason", out var rsn) ? rsn.GetString() : null; if (!isReport) { report.AiSummary = $"AI 暂时无法完成解读:{reason ?? "这可能不是一份医学报告"}。请重新上传清晰的报告图片。"; report.AiIndicators = "[]"; report.Status = ReportStatus.AnalysisFailed; await _reports.SaveChangesAsync(ct); await NotifyAsync(report, job.TaskId, false, ct); return; } var reportType = vlmJson.TryGetProperty("reportType", out var rt) ? rt.GetString() ?? "Other" : "Other"; var indicatorsJson = vlmJson.TryGetProperty("indicators", out var inds) ? inds.GetRawText() : "[]"; report.Category = Enum.TryParse(reportType, out var cat) ? cat : ReportCategory.Other; report.AiIndicators = indicatorsJson; report.AiSummary = await GenerateSummaryAsync(indicatorsJson, ct); report.Status = ReportStatus.PendingDoctor; await _reports.SaveChangesAsync(ct); await NotifyAsync(report, job.TaskId, true, ct); _logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { // 进程停机导致的取消:不算失败,不消耗重试,交还给恢复机制 throw; } catch (Exception ex) { _logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId); report.Status = ReportStatus.AnalysisFailed; report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。"; report.AiIndicators = "[]"; await _reports.SaveChangesAsync(ct); // 重新抛出,使 Worker 进入 RetryAsync(指数退避重试,超限后任务置 Failed); // 若为可恢复的瞬时故障,重试成功会把状态改回 PendingDoctor。 throw; } } private async Task ExtractIndicatorsAsync(string filePath, CancellationToken ct) { var imageUrl = await GetLocalImageUrl(filePath, ct); if (imageUrl == null) throw new InvalidOperationException("报告图片文件不存在或无法读取"); var prompt = """ 你是一名医学检验报告分析专家。请仔细识别这份医学报告。 第一步:判断这到底是不是一份医学检验报告。 如果不是(比如是自拍、风景照、食物、文件等),直接返回: {"isReport": false, "reason": "这不是医学报告,原因:..."} 如果是医学报告,提取所有检测指标,返回严格JSON格式(不要markdown包裹): { "isReport": true, "reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other", "indicators": [ {"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"} ] } 注意: - status 判断:数值在参考范围内=normal,超出上限=high,低于下限=low - 如果参考范围不明确,根据常见医学标准判断 """; var response = await _vision.VisionAsync( prompt, [imageUrl], userText: "请分析这份医学报告", maxTokens: 2048, ct: ct); var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? ""; // 不记录 VLM 返回正文:可能包含检验指标等敏感医疗信息,生产日志只记录长度 _logger.LogInformation("报告 VLM 返回长度: {Length}", content.Length); var json = TryParseJson(content); return json ?? throw new InvalidOperationException("报告识别结果格式异常"); } private async Task GenerateSummaryAsync(string indicatorsJson, CancellationToken ct) { var prompt = $""" 你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。 检测指标:{indicatorsJson} 要求: 1. 总字数200-300字 2. 先总结整体情况 3. 指出异常指标及其可能原因,但不要给出确定诊断 4. 给出生活方式和复查/就医沟通建议,不要给出处方、停药、换药或调药建议 5. 如指标明显异常或存在急症风险,优先建议及时就医 6. 末尾提醒"以上为AI预解读,不能替代医生诊断和治疗建议" 只返回解读文本,不要JSON格式。 """; var messages = new List { new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" }, new() { Role = "user", Content = prompt } }; var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct); var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? ""; return !string.IsNullOrWhiteSpace(summary) ? summary : throw new InvalidOperationException("AI 未返回有效解读内容"); } private static async Task GetLocalImageUrl(string filePath, CancellationToken ct) { if (!File.Exists(filePath)) return null; var bytes = await File.ReadAllBytesAsync(filePath, ct); var base64 = Convert.ToBase64String(bytes); var ext = Path.GetExtension(filePath).ToLowerInvariant().TrimStart('.'); var mime = ext switch { "png" => "image/png", "jpg" or "jpeg" => "image/jpeg", "webp" => "image/webp", _ => "image/png" }; return $"data:{mime};base64,{base64}"; } private static JsonElement? TryParseJson(string? content) { if (string.IsNullOrWhiteSpace(content)) return null; content = content.Trim(); if (content.StartsWith("```")) { var end = content.IndexOf('\n'); if (end > 0) content = content[(end + 1)..]; if (content.EndsWith("```")) content = content[..^3]; } content = content.Trim(); try { return JsonDocument.Parse(content).RootElement; } catch (JsonException) { return null; } } private Task NotifyAsync(Report report, Guid sourceId, bool succeeded, CancellationToken ct) => _notifications.EnqueueAsync( report.UserId, sourceId, new NotificationMessage( succeeded ? "ReportAnalysisCompleted" : "ReportAnalysisFailed", succeeded ? "报告分析完成" : "报告分析未完成", succeeded ? "您的报告已完成 AI 预解读,点击查看分析结果。" : "这份报告暂时无法完成分析,您可以进入报告页面重新尝试。", succeeded ? "success" : "warning", "report", report.Id.ToString()), ct); }