- 报告模块:重写AI解读页,统一白色卡片+三栏布局(指标→解读→医生审核);加删除功能+后端删接口;VLM自动识别报告类型生成中文标题;去五颜六色
- 饮食分析:全面重设计,暖橙主题配色,图片自适应,餐次emoji选择器,去紫色
- 运动确认卡片:修复duration_minutes JSON类型转换,支持中文数字识别(三十分钟/半小时→30);主区域只显示运动名,字段行改为横向排列
- 流式输出:简化为最基础逐字追加,去buffer+Timer+淡入动画
- 欢迎卡片:每智能体独立渐变色(青/橙/蓝/紫/绿/粉),加卡片入场滑入+淡入动画(600ms)
- 确认卡片:头部去紫色背景改浅灰白,字段行去图标改纯信息横排,编辑框去双重边框
- 用药管理:胶囊改TabBar,添加按钮改右下FAB;打卡按钮改对号圆圈形式;药丸图标白底
- 侧边栏:加VIP服务/保险栏+图标,标题字体放大;服务包去查看更多+去VIP金色标签
- 胶囊:首页智能体胶囊白底深色字; side胶囊加阴影
- 对话流:reverse=false,今日健康出现在顶部; 对话页input bar匹配主页面样式
- 其他:个人资料去紫色+去多余入口;设置页白底图标;蓝牙页加返回按钮;报告管理改名
- 后端:加DELETE /api/reports/{id}; 修复manage_exercise数据类型转换;AI提示优化中文数字
301 lines
14 KiB
C#
301 lines
14 KiB
C#
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<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); |