fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒
- SignalR Hub消息持久化+防重复+跨端广播 - 报告VLM+LLM真实AI流程(验证→提取→解读) - 医生审核页重构(严重程度/建议模板/综合评语) - 健康概览五合一趋势图(fl_chart+指标切换) - 用药提醒时区修复+跨午夜+过期提醒 - 运动打卡DayOfWeek映射修复+计划覆盖查询 - 体重与其他四指标同级(Abnormal检查/确认卡牌) - AI Agent支持多种类多时段批量录入 - 删除硬编码假数据(张三/假医生/假AI解读) - 随访/报告审核数据链路全部打通
@@ -17,7 +17,9 @@ public sealed class Report
|
||||
[Column(TypeName = "jsonb")]
|
||||
public string? AiIndicators { get; set; } // JSONB: [{name, value, unit, range, status}]
|
||||
public ReportStatus Status { get; set; }
|
||||
public string? DoctorComment { get; set; } // 医生审核意见
|
||||
public string? Severity { get; set; } // 严重程度:Normal/Abnormal/Severe/Critical
|
||||
public string? DoctorComment { get; set; } // 医生审核意见
|
||||
public string? DoctorRecommendation { get; set; } // 医生建议(用药/复查/生活方式)
|
||||
public string? DoctorName { get; set; }
|
||||
public DateTime? ReviewedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -67,6 +67,7 @@ public static class HealthDataAgentHandler
|
||||
record.MetricType = HealthMetricType.Weight;
|
||||
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
|
||||
record.Unit = "kg";
|
||||
record.IsAbnormal = false;
|
||||
break;
|
||||
default:
|
||||
return new { success = false, message = $"未知指标类型: {type}" };
|
||||
|
||||
@@ -55,11 +55,13 @@ public sealed class PromptManager
|
||||
|
||||
规则:
|
||||
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
|
||||
2. 指标明确+数值明确→直接录入
|
||||
3. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
|
||||
4. 时间模糊→取当前时间,在确认卡片中告知用户
|
||||
5. 数值超出正常范围→附带异常提醒
|
||||
6. 录入后生成确认卡片格式的回复
|
||||
2. 指标明确+数值明确→直接调用 record_health_data 录入
|
||||
3. 用户可以一次性说多个指标(如"血压120/80,血糖6.2,血氧97")→多次调用 record_health_data
|
||||
4. 用户可以分时段说(如"早上血压120,下午血压130")→分别录入,recorded_at 参数用不同时间
|
||||
5. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
|
||||
6. 时间模糊→取当前时间
|
||||
7. 数值超出正常范围→附带异常提醒
|
||||
8. 每次调用 record_health_data 后,继续调用下一个,全部录入完成后生成确认卡片格式的回复
|
||||
|
||||
正常值参考范围:
|
||||
- 收缩压 90-139 mmHg,舒张压 60-89 mmHg
|
||||
|
||||
@@ -7,13 +7,13 @@ public static class DataSeeder
|
||||
{
|
||||
public static async Task SeedAsync(AppDbContext db)
|
||||
{
|
||||
// 种子医生数据
|
||||
// 种子医生数据(固定 ID,与客户端 fallback 保持一致)
|
||||
if (!db.Doctors.Any())
|
||||
{
|
||||
db.Doctors.AddRange(
|
||||
new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = Guid.Parse("ef0953c9-eb63-4d03-b6d7-050a1897d4a3"),
|
||||
Name = "王建国",
|
||||
Title = "主任医师",
|
||||
Department = "心血管内科",
|
||||
@@ -22,7 +22,7 @@ public static class DataSeeder
|
||||
},
|
||||
new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = Guid.Parse("d4148733-b538-4398-af17-0c7592fc0c2d"),
|
||||
Name = "李芳",
|
||||
Title = "副主任医师",
|
||||
Department = "营养科",
|
||||
@@ -31,7 +31,7 @@ public static class DataSeeder
|
||||
},
|
||||
new Doctor
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = Guid.Parse("468b82e2-d95a-4436-bff6-a50eecf99a66"),
|
||||
Name = "张明",
|
||||
Title = "主任医师",
|
||||
Department = "心脏康复科",
|
||||
|
||||
@@ -14,6 +14,9 @@ public static class DevDataSeeder
|
||||
var enabled = config["DEVDATA_ENABLED"]?.ToLowerInvariant();
|
||||
if (enabled != "true") return;
|
||||
|
||||
// 已有任何真实用户时跳过(避免污染真实数据)
|
||||
if (db.Users.Any(u => u.Phone != "13800000001")) return;
|
||||
|
||||
// 检查是否已有测试用户(避免重复填充)
|
||||
if (db.Users.Any(u => u.Phone == "13800000001")) return;
|
||||
|
||||
|
||||
@@ -34,13 +34,15 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
|
||||
// 使用北京时间(UTC+8)
|
||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||
var beijingTime = TimeOnly.FromDateTime(beijingNow);
|
||||
var today = DateOnly.FromDateTime(beijingNow);
|
||||
var todayStart = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc);
|
||||
|
||||
// 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内(防止服务重启错过提醒)
|
||||
// 处理跨午夜情况
|
||||
var windowStart = beijingTime.AddMinutes(-5);
|
||||
var medications = await db.Medications
|
||||
.Where(m => m.IsActive)
|
||||
.Where(m => m.TimeOfDay.Any(t => t >= windowStart && t <= beijingTime))
|
||||
.Where(m => m.TimeOfDay.Any(t =>
|
||||
beijingTime > windowStart ? (t >= windowStart && t <= beijingTime) : (t >= windowStart || t <= beijingTime)))
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var med in medications)
|
||||
@@ -48,7 +50,7 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
|
||||
// 检查今天是否已打卡
|
||||
var alreadyLogged = await db.MedicationLogs
|
||||
.AnyAsync(l => l.MedicationId == med.Id
|
||||
&& l.CreatedAt.Date == beijingNow.Date
|
||||
&& l.CreatedAt >= todayStart
|
||||
&& l.Status == MedicationLogStatus.Taken, ct);
|
||||
|
||||
if (alreadyLogged) continue;
|
||||
|
||||
@@ -402,27 +402,39 @@ public static class AiChatEndpoints
|
||||
|
||||
private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata)
|
||||
{
|
||||
// 将匿名对象统一转成 Dictionary(toolResult 可能是匿名类型,不是 IDictionary)
|
||||
var resultDict = toolResult as IDictionary<string, object>;
|
||||
if (resultDict == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(toolResult, JsonOpts);
|
||||
resultDict = JsonSerializer.Deserialize<Dictionary<string, object>>(json, JsonOpts);
|
||||
}
|
||||
catch { resultDict = new Dictionary<string, object>(); }
|
||||
}
|
||||
|
||||
switch (toolName)
|
||||
{
|
||||
case "record_health_data":
|
||||
messageType = "data_confirm";
|
||||
if (toolResult is IDictionary<string, object> resultDict)
|
||||
if (resultDict != null)
|
||||
{
|
||||
if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type.ToString();
|
||||
if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val.ToString();
|
||||
if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit.ToString();
|
||||
if (resultDict.TryGetValue("isAbnormal", out var abn) && abn is bool b2) metadata["abnormal"] = b2;
|
||||
if (resultDict.TryGetValue("success", out var success) && success is bool b && b)
|
||||
metadata["success"] = true;
|
||||
if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = abn is bool b2 ? b2 : false;
|
||||
if (resultDict.TryGetValue("success", out var success)) metadata["success"] = success is bool b && b;
|
||||
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
||||
}
|
||||
break;
|
||||
case "manage_medication":
|
||||
messageType = "medication_confirm";
|
||||
if (toolResult is IDictionary<string, object> medDict)
|
||||
if (resultDict != null)
|
||||
{
|
||||
if (medDict.TryGetValue("name", out var name))
|
||||
if (resultDict.TryGetValue("name", out var name))
|
||||
metadata["name"] = name.ToString();
|
||||
if (medDict.TryGetValue("dosage", out var dosage))
|
||||
if (resultDict.TryGetValue("dosage", out var dosage))
|
||||
metadata["dosage"] = dosage.ToString();
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Health.WebApi.Hubs;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class ConsultationEndpoints
|
||||
@@ -43,12 +45,32 @@ public static class ConsultationEndpoints
|
||||
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, IHubContext<Hubs.ConsultationHub> hubContext, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
|
||||
db.ConsultationMessages.Add(msg);
|
||||
|
||||
// 用户发消息后,状态从 AiTalking → WaitingDoctor
|
||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||
if (consultation != null && consultation.Status == ConsultationStatus.AiTalking)
|
||||
{
|
||||
consultation.Status = ConsultationStatus.WaitingDoctor;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// 通过 SignalR 广播给问诊房间(医生端实时接收)
|
||||
await hubContext.Clients.Group(id.ToString()).SendAsync("ReceiveMessage", new
|
||||
{
|
||||
msg.Id,
|
||||
consultationId = id.ToString(),
|
||||
senderType = "User",
|
||||
senderName = (string?)null,
|
||||
content = req.Content,
|
||||
msg.CreatedAt
|
||||
});
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { msg.Id }, message = (string?)null });
|
||||
});
|
||||
|
||||
|
||||
@@ -137,9 +137,11 @@ public static class DoctorEndpoints
|
||||
FileType = r.FileType.ToString(),
|
||||
Category = r.Category.ToString(),
|
||||
Status = r.Status.ToString(),
|
||||
r.Severity,
|
||||
r.AiSummary,
|
||||
r.AiIndicators,
|
||||
r.DoctorComment,
|
||||
r.DoctorRecommendation,
|
||||
r.DoctorName,
|
||||
r.ReviewedAt,
|
||||
r.CreatedAt
|
||||
@@ -342,8 +344,12 @@ public static class DoctorEndpoints
|
||||
FileType = r.FileType.ToString(),
|
||||
Category = r.Category.ToString(),
|
||||
Status = r.Status.ToString(),
|
||||
r.Severity,
|
||||
r.AiSummary,
|
||||
r.AiIndicators,
|
||||
r.DoctorComment,
|
||||
r.DoctorRecommendation,
|
||||
r.DoctorName,
|
||||
r.ReviewedAt,
|
||||
r.CreatedAt
|
||||
})
|
||||
@@ -368,7 +374,9 @@ public static class DoctorEndpoints
|
||||
Status = r.Status.ToString(),
|
||||
r.AiSummary,
|
||||
r.AiIndicators,
|
||||
r.Severity,
|
||||
r.DoctorComment,
|
||||
r.DoctorRecommendation,
|
||||
r.DoctorName,
|
||||
r.ReviewedAt,
|
||||
r.CreatedAt
|
||||
@@ -391,14 +399,21 @@ public static class DoctorEndpoints
|
||||
|
||||
using var reader = new StreamReader(http.Request.Body);
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||
var comment = json.RootElement.GetProperty("comment").GetString() ?? "";
|
||||
var json = JsonDocument.Parse(body);
|
||||
|
||||
report.DoctorComment = comment;
|
||||
report.Severity = json.RootElement.TryGetProperty("severity", out var sv) ? sv.GetString() : null;
|
||||
report.DoctorComment = json.RootElement.TryGetProperty("comment", out var cm) ? cm.GetString() : null;
|
||||
report.DoctorRecommendation = json.RootElement.TryGetProperty("recommendation", out var rc) ? rc.GetString() : null;
|
||||
report.DoctorName = doctor.Name;
|
||||
report.ReviewedAt = DateTime.UtcNow;
|
||||
report.Status = ReportStatus.DoctorReviewed;
|
||||
|
||||
// 如果医生提供了修正后的指标
|
||||
if (json.RootElement.TryGetProperty("indicators", out var inds))
|
||||
{
|
||||
report.AiIndicators = inds.GetRawText();
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
|
||||
@@ -9,9 +9,13 @@ public static class ExerciseEndpoints
|
||||
group.MapGet("/current", async (HttpContext http, AppDbContext db) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
var monday = today.AddDays(-(int)today.DayOfWeek + 1);
|
||||
var plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.UserId == userId && p.WeekStartDate == monday);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); // 北京时间
|
||||
// 查找覆盖今天的所有计划(不仅限于周一开始的)
|
||||
var plans = await db.ExercisePlans.Include(p => p.Items)
|
||||
.Where(p => p.UserId == userId && p.WeekStartDate <= today)
|
||||
.OrderByDescending(p => p.WeekStartDate)
|
||||
.ToListAsync();
|
||||
var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.DayOfWeek == (int)today.DayOfWeek));
|
||||
if (plan == null) return Results.Ok(new { code = 0, data = (object?)null, message = (string?)null });
|
||||
return Results.Ok(new
|
||||
{
|
||||
|
||||
38
backend/src/Health.WebApi/Endpoints/followup_endpoints.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// 患者端随访 API
|
||||
/// </summary>
|
||||
public static class FollowUpEndpoints
|
||||
{
|
||||
public static void MapFollowUpEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/follow-ups").RequireAuthorization();
|
||||
|
||||
// 患者查看自己的随访列表
|
||||
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var followUps = await db.FollowUps
|
||||
.Where(f => f.UserId == userId)
|
||||
.OrderByDescending(f => f.ScheduledAt)
|
||||
.Select(f => new
|
||||
{
|
||||
f.Id,
|
||||
f.Title,
|
||||
f.DoctorName,
|
||||
f.Department,
|
||||
f.ScheduledAt,
|
||||
f.Notes,
|
||||
Status = f.Status.ToString(),
|
||||
f.CreatedAt
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Results.Ok(new { code = 0, data = followUps, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
@@ -115,6 +115,7 @@ public static class HealthEndpoints
|
||||
HealthMetricType.HeartRate => req.Value > 100 || req.Value < 60,
|
||||
HealthMetricType.Glucose => req.Value >= 7.0m || req.Value <= 3.8m,
|
||||
HealthMetricType.SpO2 => req.Value <= 94,
|
||||
HealthMetricType.Weight => false, // 体重无标准异常范围
|
||||
_ => false
|
||||
};
|
||||
|
||||
|
||||
@@ -106,19 +106,28 @@ public static class MedicationEndpoints
|
||||
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var now = TimeOnly.FromDateTime(DateTime.Now);
|
||||
var windowEnd = now.AddHours(1);
|
||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||
var now = TimeOnly.FromDateTime(beijingNow);
|
||||
var windowFuture = now.AddHours(2); // 接下来2小时
|
||||
var windowPast = now.AddHours(-1); // 过去1小时内(过了还没吃就该提醒)
|
||||
|
||||
var meds = await db.Medications
|
||||
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null)
|
||||
.ToListAsync(ct);
|
||||
var due = meds.Where(m => m.TimeOfDay!.Any(t => t >= now && t <= windowEnd)).ToList();
|
||||
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
// 同时包含:未来2小时内 + 过去1小时内(防止过了时间就不提醒)
|
||||
var due = meds.Where(m => m.TimeOfDay!.Any(t =>
|
||||
InTimeWindow(t, now, windowFuture) || InTimeWindow(t, windowPast, now)
|
||||
)).ToList();
|
||||
|
||||
var todayStart = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc);
|
||||
var dueMeds = new List<object>();
|
||||
foreach (var m in due)
|
||||
{
|
||||
var logged = await db.MedicationLogs.AnyAsync(l =>
|
||||
l.MedicationId == m.Id && l.CreatedAt >= today.ToDateTime(TimeOnly.MinValue) && l.Status == MedicationLogStatus.Taken, ct);
|
||||
l.MedicationId == m.Id
|
||||
&& l.CreatedAt >= todayStart
|
||||
&& l.Status == MedicationLogStatus.Taken, ct);
|
||||
if (!logged)
|
||||
dueMeds.Add(new { m.Id, m.Name, m.Dosage, m.TimeOfDay });
|
||||
}
|
||||
@@ -127,6 +136,9 @@ public static class MedicationEndpoints
|
||||
});
|
||||
}
|
||||
|
||||
private static bool InTimeWindow(TimeOnly t, TimeOnly start, TimeOnly end) =>
|
||||
end > start ? (t >= start && t <= end) : (t >= start || t <= end); // 处理跨午夜
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -4,9 +4,14 @@ namespace Health.WebApi.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// 问诊实时通信 Hub(开发阶段跳过认证)
|
||||
/// 消息同时持久化到 DB + 广播到组
|
||||
/// </summary>
|
||||
public sealed class ConsultationHub : Hub
|
||||
{
|
||||
private readonly IServiceProvider _sp;
|
||||
|
||||
public ConsultationHub(IServiceProvider sp) => _sp = sp;
|
||||
|
||||
public async Task JoinConsultation(string consultationId)
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, consultationId);
|
||||
@@ -18,19 +23,49 @@ public sealed class ConsultationHub : Hub
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送消息到问诊房间
|
||||
/// 发送消息:先存 DB,再广播给组内其他人(不发给自己避免重复)
|
||||
/// 返回服务器端消息详情(含真实 ID)
|
||||
/// </summary>
|
||||
public async Task SendMessage(string consultationId, string senderType, string? senderName, string content)
|
||||
public async Task<object> SendMessage(string consultationId, string senderType, string? senderName, string content)
|
||||
{
|
||||
// 广播给组内所有人(包括自己)
|
||||
await Clients.Group(consultationId).SendAsync("ReceiveMessage", new
|
||||
var msgId = Guid.NewGuid();
|
||||
var createdAt = DateTime.UtcNow;
|
||||
|
||||
// 持久化到数据库
|
||||
using var scope = _sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var msg = new ConsultationMessage
|
||||
{
|
||||
id = Guid.NewGuid(),
|
||||
Id = msgId,
|
||||
ConsultationId = Guid.Parse(consultationId),
|
||||
SenderType = Enum.Parse<ConsultationSenderType>(senderType),
|
||||
SenderName = senderName,
|
||||
Content = content,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
db.ConsultationMessages.Add(msg);
|
||||
|
||||
// 更新问诊状态(如果用户发了消息,说明 AI 阶段结束)
|
||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == msg.ConsultationId);
|
||||
if (consultation != null && consultation.Status == ConsultationStatus.AiTalking)
|
||||
{
|
||||
consultation.Status = ConsultationStatus.WaitingDoctor;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// 广播给组内其他人(不包括发送者,避免客户端重复显示)
|
||||
await Clients.OthersInGroup(consultationId).SendAsync("ReceiveMessage", new
|
||||
{
|
||||
id = msgId,
|
||||
consultationId,
|
||||
senderType,
|
||||
senderName,
|
||||
content,
|
||||
createdAt = DateTime.UtcNow
|
||||
createdAt
|
||||
});
|
||||
|
||||
// 返回服务器消息详情给调用者(用于更新本地 ID)
|
||||
return new { id = msgId, consultationId, senderType, senderName, content, createdAt };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ app.MapAiChatEndpoints();
|
||||
app.MapFileEndpoints();
|
||||
app.MapCalendarEndpoints();
|
||||
app.MapDoctorEndpoints();
|
||||
app.MapFollowUpEndpoints();
|
||||
|
||||
// SignalR Hub
|
||||
app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");
|
||||
|
||||
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 914 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 602 KiB |