Files
AI-Health/backend/src/Health.WebApi/Endpoints/report_endpoints.cs
MingNian f6dd14be3b feat: 全面UI改造 — 报告/饮食/运动/用药/侧边栏/对话流/胶囊
- 报告模块:重写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提示优化中文数字
2026-06-10 23:46:45 +08:00

301 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);