后端 - 改用 EF Core Migrations(InitialCreate),Program.cs 用 MigrateAsync + AUTO_MIGRATE 开关 + 显式 MigrationsAssembly;移除 EnsureCreated、删除手写 DatabaseSchemaMigrator - 修复后台任务永久卡 Processing:三个队列对超时且达上限的任务原子标记 Failed - 修复报告重试失效:基础设施异常向上抛激活 RetryAsync,停机取消不计失败 - 修复 AI 用药天数 off-by-one(结束日为包含式) - AI 报告日志不再记录 VLM 健康正文 - 新增统一输入校验(ValidationException + 中间件映射 400):血压/心率/血糖/血氧/体重范围、药名/日期/服药时间去重、饮食热量与评分、运动时长上限 - 删除健康数据 AI 录入的影子直写路径,统一走 Service 校验 前端 - 新增 AppErrorState / AppFutureView,用药列表、服药打卡、运动计划、随访列表改为 加载/失败/空/数据 四态,失败可重试 瘦身 - 删除过时的 DataSeeder / DevDataSeeder 及调用 - 删除依赖实时服务的 ai_agent_tests 集成测试
194 lines
8.4 KiB
C#
194 lines
8.4 KiB
C#
using Health.Application.Reports;
|
||
using Health.Application.Notifications;
|
||
using Health.Infrastructure.AI;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace Health.Infrastructure.Reports;
|
||
|
||
public sealed class ReportAnalysisService(
|
||
IReportRepository reports,
|
||
VisionClient vision,
|
||
DeepSeekClient llm,
|
||
IUserNotificationProducer notifications,
|
||
ILogger<ReportAnalysisService> logger) : IReportAnalysisService
|
||
{
|
||
private readonly IReportRepository _reports = reports;
|
||
private readonly VisionClient _vision = vision;
|
||
private readonly DeepSeekClient _llm = llm;
|
||
private readonly IUserNotificationProducer _notifications = notifications;
|
||
private readonly ILogger<ReportAnalysisService> _logger = logger;
|
||
|
||
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
|
||
{
|
||
var report = await _reports.GetByIdAsync(job.ReportId, ct);
|
||
if (report == null) return;
|
||
|
||
try
|
||
{
|
||
var vlmJson = await ExtractIndicatorsAsync(job.FilePath, ct);
|
||
|
||
var isReport = vlmJson.TryGetProperty("isReport", out var ir) && ir.GetBoolean();
|
||
var reason = vlmJson.TryGetProperty("reason", out var rsn) ? rsn.GetString() : null;
|
||
if (!isReport)
|
||
{
|
||
report.AiSummary = $"AI 暂时无法完成解读:{reason ?? "这可能不是一份医学报告"}。请重新上传清晰的报告图片。";
|
||
report.AiIndicators = "[]";
|
||
report.Status = ReportStatus.AnalysisFailed;
|
||
await _reports.SaveChangesAsync(ct);
|
||
await NotifyAsync(report, job.TaskId, false, ct);
|
||
return;
|
||
}
|
||
|
||
var reportType = vlmJson.TryGetProperty("reportType", out var rt) ? rt.GetString() ?? "Other" : "Other";
|
||
var indicatorsJson = vlmJson.TryGetProperty("indicators", out var inds) ? inds.GetRawText() : "[]";
|
||
|
||
report.Category = Enum.TryParse<ReportCategory>(reportType, out var cat) ? cat : ReportCategory.Other;
|
||
report.AiIndicators = indicatorsJson;
|
||
report.AiSummary = await GenerateSummaryAsync(indicatorsJson, ct);
|
||
report.Status = ReportStatus.PendingDoctor;
|
||
|
||
await _reports.SaveChangesAsync(ct);
|
||
await NotifyAsync(report, job.TaskId, true, ct);
|
||
_logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId);
|
||
}
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||
{
|
||
// 进程停机导致的取消:不算失败,不消耗重试,交还给恢复机制
|
||
throw;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId);
|
||
report.Status = ReportStatus.AnalysisFailed;
|
||
report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。";
|
||
report.AiIndicators = "[]";
|
||
await _reports.SaveChangesAsync(ct);
|
||
|
||
// 重新抛出,使 Worker 进入 RetryAsync(指数退避重试,超限后任务置 Failed);
|
||
// 若为可恢复的瞬时故障,重试成功会把状态改回 PendingDoctor。
|
||
throw;
|
||
}
|
||
}
|
||
|
||
private async Task<JsonElement> ExtractIndicatorsAsync(string filePath, CancellationToken ct)
|
||
{
|
||
var imageUrl = await GetLocalImageUrl(filePath, ct);
|
||
if (imageUrl == null)
|
||
throw new InvalidOperationException("报告图片文件不存在或无法读取");
|
||
|
||
var prompt = """
|
||
你是一名医学检验报告分析专家。请仔细识别这份医学报告。
|
||
|
||
第一步:判断这到底是不是一份医学检验报告。
|
||
如果不是(比如是自拍、风景照、食物、文件等),直接返回:
|
||
{"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 response = await _vision.VisionAsync(
|
||
prompt,
|
||
[imageUrl],
|
||
userText: "请分析这份医学报告",
|
||
maxTokens: 2048,
|
||
ct: ct);
|
||
|
||
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
|
||
// 不记录 VLM 返回正文:可能包含检验指标等敏感医疗信息,生产日志只记录长度
|
||
_logger.LogInformation("报告 VLM 返回长度: {Length}", content.Length);
|
||
|
||
var json = TryParseJson(content);
|
||
return json ?? throw new InvalidOperationException("报告识别结果格式异常");
|
||
}
|
||
|
||
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
|
||
{
|
||
var prompt = $"""
|
||
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
|
||
|
||
检测指标:{indicatorsJson}
|
||
|
||
要求:
|
||
1. 总字数200-300字
|
||
2. 先总结整体情况
|
||
3. 指出异常指标及其可能原因,但不要给出确定诊断
|
||
4. 给出生活方式和复查/就医沟通建议,不要给出处方、停药、换药或调药建议
|
||
5. 如指标明显异常或存在急症风险,优先建议及时就医
|
||
6. 末尾提醒"以上为AI预解读,不能替代医生诊断和治疗建议"
|
||
|
||
只返回解读文本,不要JSON格式。
|
||
""";
|
||
|
||
var messages = new List<ChatMessage>
|
||
{
|
||
new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" },
|
||
new() { Role = "user", Content = prompt }
|
||
};
|
||
|
||
var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct);
|
||
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
|
||
return !string.IsNullOrWhiteSpace(summary)
|
||
? summary
|
||
: throw new InvalidOperationException("AI 未返回有效解读内容");
|
||
}
|
||
|
||
private static async Task<string?> GetLocalImageUrl(string filePath, CancellationToken ct)
|
||
{
|
||
if (!File.Exists(filePath)) return null;
|
||
|
||
var bytes = await File.ReadAllBytesAsync(filePath, ct);
|
||
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();
|
||
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 (JsonException) { return null; }
|
||
}
|
||
|
||
private Task NotifyAsync(Report report, Guid sourceId, bool succeeded, CancellationToken ct) =>
|
||
_notifications.EnqueueAsync(
|
||
report.UserId,
|
||
sourceId,
|
||
new NotificationMessage(
|
||
succeeded ? "ReportAnalysisCompleted" : "ReportAnalysisFailed",
|
||
succeeded ? "报告分析完成" : "报告分析未完成",
|
||
succeeded ? "您的报告已完成 AI 预解读,点击查看分析结果。" : "这份报告暂时无法完成分析,您可以进入报告页面重新尝试。",
|
||
succeeded ? "success" : "warning",
|
||
"report",
|
||
report.Id.ToString()),
|
||
ct);
|
||
}
|