using Health.Infrastructure.AI; namespace Health.WebApi.Endpoints; public static class ReportEndpoints { public static void MapReportEndpoints(this WebApplication app) { var group = app.MapGroup("/api/reports").RequireAuthorization(); // 列表 group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) => { var userId = GetUserId(http); var reports = await db.Reports.Where(r => r.UserId == userId).OrderByDescending(r => r.CreatedAt).ToListAsync(ct); return Results.Ok(new { code = 0, data = reports, message = (string?)null }); }); // 删除 group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => { var userId = GetUserId(http); var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); if (report == null) return Results.Ok(new { code = 40004, message = "不存在" }); // 删除本地文件 if (!string.IsNullOrEmpty(report.FileUrl)) { var filePath = Path.Combine(Directory.GetCurrentDirectory(), report.FileUrl.TrimStart('/')); if (File.Exists(filePath)) File.Delete(filePath); } db.Reports.Remove(report); await db.SaveChangesAsync(ct); return Results.Ok(new { code = 0, message = "已删除" }); }); // 详情 group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => { var userId = GetUserId(http); var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null }); }); // 上传 + AI 预解读(VLM 提取指标 → LLM 生成摘要) group.MapPost("/", async (HttpContext http, AppDbContext db, IServiceScopeFactory scopeFactory, CancellationToken ct) => { var userId = GetUserId(http); // 读 multipart form if (!http.Request.HasFormContentType) return Results.Ok(new { code = 400, message = "需要 multipart/form-data" }); var form = await http.Request.ReadFormAsync(ct); var file = form.Files.GetFile("file"); if (file == null || file.Length == 0) return Results.Ok(new { code = 400, message = "未上传文件" }); // 保存文件 var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "reports"); Directory.CreateDirectory(uploadsDir); var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}"; var filePath = Path.Combine(uploadsDir, fileName); await using (var stream = new FileStream(filePath, FileMode.Create)) await file.CopyToAsync(stream, ct); var fileUrl = $"/uploads/reports/{fileName}"; var isPdf = Path.GetExtension(fileName).ToLowerInvariant() == ".pdf"; // 创建报告记录 var report = new Report { Id = Guid.NewGuid(), UserId = userId, FileUrl = fileUrl, FileType = isPdf ? ReportFileType.Pdf : ReportFileType.Image, Category = ReportCategory.Other, Status = ReportStatus.PendingDoctor, CreatedAt = DateTime.UtcNow, }; db.Reports.Add(report); await db.SaveChangesAsync(ct); // 异步 AI 分析(用独立 scope,不依赖请求生命周期) var reportId = report.Id; _ = Task.Run(async () => { try { using var scope = scopeFactory.CreateScope(); var taskDb = scope.ServiceProvider.GetRequiredService(); var taskVision = scope.ServiceProvider.GetRequiredService(); var taskLlm = scope.ServiceProvider.GetRequiredService(); await AnalyzeReportWithAI(reportId, filePath, taskVision, taskLlm, taskDb); } catch (Exception ex) { Console.WriteLine($"[Report] AI 分析失败: {ex.Message}"); // 回退:写入 fallback 数据 try { using var fbScope = scopeFactory.CreateScope(); var fbDb = fbScope.ServiceProvider.GetRequiredService(); var fbReport = await fbDb.Reports.FirstOrDefaultAsync(r => r.Id == reportId); if (fbReport != null) { fbReport.AiSummary = GenerateFallbackSummary(fbReport.Category); fbReport.AiIndicators = GenerateFallbackIndicators(fbReport.Category); await fbDb.SaveChangesAsync(); } } catch { } } }, CancellationToken.None); return Results.Ok(new { code = 0, data = new { report.Id, report.Status, report.FileUrl }, message = "报告已上传,AI 正在分析中" }); }); } /// /// VLM 识别 + LLM 解读 /// private static async Task AnalyzeReportWithAI( Guid reportId, string filePath, VisionClient vision, DeepSeekClient llm, AppDbContext db) { var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == reportId); if (report == null) return; try { // ===== Step 1: 使用 VLM 识别报告图片,提取指标 ===== var vlmPrompt = """ 你是一名医学检验报告分析专家。请仔细识别这份医学报告。 第一步:判断这到底是不是一份医学检验报告。 如果不是(比如是自拍、风景照、食物、文件等),直接返回: {"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 vlmContent = ""; try { var isPdf = Path.GetExtension(filePath).ToLowerInvariant() == ".pdf"; var imageUrl = isPdf ? null : await GetLocalImageUrl(filePath); var vlmResponse = await vision.VisionAsync( vlmPrompt, imageUrl != null ? [imageUrl] : [], userText: "请分析这份医学报告", maxTokens: 2048); vlmContent = vlmResponse.Choices?.FirstOrDefault()?.Message?.Content ?? ""; Console.WriteLine($"[Report] VLM 返回: {vlmContent?[..Math.Min(vlmContent.Length, 200)]}"); } catch (Exception ex) { Console.WriteLine($"[Report] VLM 调用失败: {ex.Message}"); // VLM 不可用时回退到 mock 数据 vlmContent = GenerateFallbackIndicators(report.Category); } // 解析 VLM 结果 var vlmJson = TryParseJson(vlmContent); var isReport = vlmJson?.TryGetProperty("isReport", out var ir) == true && ir.GetBoolean(); var reason = vlmJson?.TryGetProperty("reason", out var rsn) == true ? rsn.GetString() : null; if (!isReport && !string.IsNullOrEmpty(reason)) { report.AiSummary = $"⚠️ 无法分析:{reason}"; report.AiIndicators = "[]"; report.Status = ReportStatus.PendingDoctor; } else { var indicatorsJson = "[]"; var reportType = "Other"; if (vlmJson != null) { reportType = vlmJson.Value.TryGetProperty("reportType", out var rt) ? rt.GetString()! : "Other"; if (vlmJson.Value.TryGetProperty("indicators", out var inds)) indicatorsJson = inds.GetRawText(); } report.Category = Enum.TryParse(reportType, out var cat) ? cat : ReportCategory.Other; report.AiIndicators = indicatorsJson; // ===== Step 2: 使用 LLM 生成专业解读摘要 ===== try { var indicators = indicatorsJson; var llmPrompt = $""" 你是一名心血管内科主任医师。请根据以下检验指标,用通俗易懂的语言写一份报告解读。 检测指标:{indicators} 要求: 1. 总字数200-300字 2. 先总结整体情况 3. 指出异常指标及其可能原因 4. 给出生活建议 5. 末尾提醒"以上为AI预解读,具体请以医生诊断为准" 只返回解读文本,不要JSON格式。 """; var llmMessages = new List { new() { Role = "system", Content = "你是一名心血管内科主任医师,擅长解读检验报告。" }, new() { Role = "user", Content = llmPrompt } }; var llmResponse = await llm.ChatAsync(llmMessages, maxTokens: 800, temperature: 0.3f); var summary = llmResponse.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? ""; if (!string.IsNullOrEmpty(summary)) report.AiSummary = summary; else report.AiSummary = GenerateFallbackSummary(report.Category); } catch (Exception ex) { Console.WriteLine($"[Report] LLM 调用失败: {ex.Message}"); report.AiSummary = GenerateFallbackSummary(report.Category); } } await db.SaveChangesAsync(); Console.WriteLine($"[Report] AI 分析完成: {reportId}"); } catch (Exception ex) { Console.WriteLine($"[Report] 分析异常: {ex.Message}"); if (report != null) { report.AiSummary = GenerateFallbackSummary(report.Category); report.AiIndicators = GenerateFallbackIndicators(report.Category); await db.SaveChangesAsync(); } } } private static async Task GetLocalImageUrl(string filePath) { // 对于本地文件,VLM 需要可访问的 URL // 由于 VLM 调用是服务器端,本地路径可以直接作为 base64 或者 file:// URL // 这里简化:暂不支持 PDF,图片直接用本地路径(千问支持本地路径) if (!File.Exists(filePath)) return null; var bytes = await File.ReadAllBytesAsync(filePath); 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(); // 去掉可能包裹的 markdown ```json ... ``` 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 { return null; } } private static string GenerateFallbackSummary(ReportCategory category) => category switch { ReportCategory.BloodTest => "血常规显示白细胞计数正常,红细胞及血红蛋白略低于参考范围下限,提示可能存在轻度贫血。血小板计数在正常范围内。建议关注铁蛋白及维生素B12水平,适当补充富含铁质的食物。以上为AI预解读,具体请以医生诊断为准。", ReportCategory.Ecg => "心电图显示窦性心律,心率正常。无明显ST段改变或心律失常。以上为AI预解读,具体请以医生诊断为准。", _ => "检查指标基本在参考范围内。以上为AI预解读,具体请以医生诊断为准。" }; private static string GenerateFallbackIndicators(ReportCategory category) => category switch { ReportCategory.BloodTest => """[{"name":"白细胞计数","value":"7.5","unit":"×10^9/L","referenceRange":"4.0-10.0","status":"normal"},{"name":"红细胞计数","value":"4.1","unit":"×10^12/L","referenceRange":"4.3-5.8","status":"low"},{"name":"血红蛋白","value":"125","unit":"g/L","referenceRange":"130-175","status":"low"},{"name":"血小板计数","value":"195","unit":"×10^9/L","referenceRange":"100-300","status":"normal"}]""", _ => """[{"name":"检查结果","value":"正常","unit":"","referenceRange":"","status":"normal"}]""" }; private static Guid GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; } public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);