Files
AI-Health/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs
MingNian 4d213b5a44 feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
2026-06-20 20:41:42 +08:00

166 lines
6.9 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.Application.Reports;
using Health.Infrastructure.AI;
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Reports;
public sealed class ReportAnalysisService(
IReportRepository reports,
VisionClient vision,
DeepSeekClient llm,
ILogger<ReportAnalysisService> logger) : IReportAnalysisService
{
private readonly IReportRepository _reports = reports;
private readonly VisionClient _vision = vision;
private readonly DeepSeekClient _llm = llm;
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);
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);
_logger.LogInformation("报告 AI 分析完成: {ReportId}", job.ReportId);
}
catch (Exception ex)
{
_logger.LogError(ex, "报告 AI 分析失败: {ReportId}", job.ReportId);
report.Status = ReportStatus.AnalysisFailed;
report.AiSummary = "AI 暂时无法完成解读,可能是图片不清晰或服务暂时不可用。请稍后重试,或重新上传更清晰的报告图片。";
report.AiIndicators = "[]";
await _reports.SaveChangesAsync(ct);
}
}
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 ?? "";
_logger.LogInformation("报告 VLM 返回: {Content}", content[..Math.Min(content.Length, 200)]);
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 { return null; }
}
}