Files
AI-Health/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs
MingNian 7a93237069 feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构
- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService
- 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放
- UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面
- 配置: api_client baseUrl 适配当前 WiFi IP
2026-07-06 12:44:59 +08:00

259 lines
11 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.Application.Notifications;
using Health.Infrastructure.AI;
using Microsoft.Extensions.Logging;
using UglyToad.PdfPig;
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;
private const int MaxPdfChars = 8000;
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)
{
if (Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase))
return await ExtractIndicatorsFromPdfAsync(filePath, 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<JsonElement> ExtractIndicatorsFromPdfAsync(string filePath, CancellationToken ct)
{
var text = ExtractPdfText(filePath);
if (string.IsNullOrWhiteSpace(text))
{
return JsonDocument.Parse("""
{"isReport": false, "reason": "这份 PDF 可能是扫描件或图片版,当前无法直接提取文字。请上传清晰图片,或后续启用 PDF 页转图片后再用 VLM 识别。"}
""").RootElement;
}
var prompt = $$"""
你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。
PDF 文本:
{{text}}
第一步:判断这到底是不是一份医学检验报告。
如果不是,直接返回:
{"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 messages = new List<ChatMessage>
{
new() { Role = "system", Content = "你是医学报告结构化抽取助手,只输出严格 JSON。" },
new() { Role = "user", Content = prompt }
};
var response = await _llm.ChatAsync(messages, maxTokens: 1800, temperature: 0.1f, ct: ct);
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
var json = TryParseJson(content);
return json ?? throw new InvalidOperationException("PDF 报告解析结果格式异常");
}
private static string ExtractPdfText(string filePath)
{
using var pdf = PdfDocument.Open(filePath);
var sb = new System.Text.StringBuilder();
foreach (var page in pdf.GetPages())
{
sb.AppendLine(page.Text);
if (sb.Length > MaxPdfChars) break;
}
var text = sb.ToString().Trim();
return text.Length > MaxPdfChars ? text[..MaxPdfChars] : text;
}
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);
}