fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒

- SignalR Hub消息持久化+防重复+跨端广播
- 报告VLM+LLM真实AI流程(验证→提取→解读)
- 医生审核页重构(严重程度/建议模板/综合评语)
- 健康概览五合一趋势图(fl_chart+指标切换)
- 用药提醒时区修复+跨午夜+过期提醒
- 运动打卡DayOfWeek映射修复+计划覆盖查询
- 体重与其他四指标同级(Abnormal检查/确认卡牌)
- AI Agent支持多种类多时段批量录入
- 删除硬编码假数据(张三/假医生/假AI解读)
- 随访/报告审核数据链路全部打通
This commit is contained in:
MingNian
2026-06-07 23:04:23 +08:00
parent d0d7d8428d
commit 287eab80a9
67 changed files with 1765 additions and 680 deletions

View File

@@ -1,3 +1,5 @@
using Health.Infrastructure.AI;
namespace Health.WebApi.Endpoints;
public static class ReportEndpoints
@@ -6,6 +8,7 @@ public static class ReportEndpoints
{
var group = app.MapGroup("/api/reports").RequireAuthorization();
// 列表
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
@@ -13,6 +16,7 @@ public static class ReportEndpoints
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
});
// 详情
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
@@ -20,25 +24,261 @@ public static class ReportEndpoints
return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null });
});
group.MapPost("/", async (CreateReportRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
// 上传 + 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 = req.FileUrl, FileType = req.FileType,
Category = req.Category ?? ReportCategory.Other,
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);
return Results.Ok(new { code = 0, data = new { report.Id }, message = (string?)null });
// 异步 AI 分析(用独立 scope不依赖请求生命周期
var reportId = report.Id;
_ = Task.Run(async () =>
{
try
{
using var scope = scopeFactory.CreateScope();
var taskDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var taskVision = scope.ServiceProvider.GetRequiredService<VisionClient>();
var taskLlm = scope.ServiceProvider.GetRequiredService<DeepSeekClient>();
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<AppDbContext>();
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 正在分析中" });
});
}
/// <summary>
/// VLM 识别 + LLM 解读
/// </summary>
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<ReportCategory>(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<ChatMessage>
{
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<string?> 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);
public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);