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
This commit is contained in:
MingNian
2026-07-06 12:44:59 +08:00
parent 4507083f3f
commit 7a93237069
38 changed files with 4165 additions and 1157 deletions

View File

@@ -2,6 +2,7 @@ using Health.Application.Reports;
using Health.Application.Notifications;
using Health.Infrastructure.AI;
using Microsoft.Extensions.Logging;
using UglyToad.PdfPig;
namespace Health.Infrastructure.Reports;
@@ -17,6 +18,7 @@ public sealed class ReportAnalysisService(
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)
{
@@ -72,6 +74,9 @@ public sealed class ReportAnalysisService(
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("报告图片文件不存在或无法读取");
@@ -112,6 +117,66 @@ public sealed class ReportAnalysisService(
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 = $"""