using System.Text.Json; using System.Text.RegularExpressions; using Health.Application.AI; using Health.Application.Diets; using Health.Application.Exercises; using Health.Application.HealthArchives; using Health.Application.HealthRecords; using Health.Application.Medications; using Health.Infrastructure.AI; using Health.Infrastructure.AI.AgentHandlers; namespace Health.WebApi.Endpoints; /// /// AI 对话 SSE 端点——支持 7 个 Agent /// public static class AiChatEndpoints { private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, }; public static void MapAiChatEndpoints(this WebApplication app) { // SSE 流式对话。认证统一走 ASP.NET Core JWT 中间件。 app.MapGet("/api/ai/{agentType}/chat", async ( string message, string? conversationId, string? imageUrl, string? pdfUrl, string agentType, HttpContext http, DeepSeekClient llmClient, PromptManager promptManager, FastGptKnowledgeClient knowledgeClient, IAiToolExecutionService toolExecution, IAiWriteConfirmationStore confirmations, IAiConversationService conversations, IAttachmentContextBuilder attachments, IPatientContextService patientContexts, CancellationToken ct) => { var userId = GetUserId(http); if (userId == null) { http.Response.StatusCode = 401; http.Response.ContentType = "application/json"; await http.Response.WriteAsync(JsonSerializer.Serialize(new { code = 40002, data = (object?)null, message = "未登录" }), ct); return; } if (!Enum.TryParse(agentType, ignoreCase: true, out var parsedType)) parsedType = AgentType.Default; // SSE 响应头 http.Response.ContentType = "text/event-stream"; http.Response.Headers.CacheControl = "no-cache"; http.Response.Headers.Connection = "keep-alive"; http.Response.Headers["X-Accel-Buffering"] = "no"; // 创建或获取对话。传入 conversationId 时必须校验归属,避免多账号切换或缓存异常导致串号。 Guid? requestedConversationId = null; if (!string.IsNullOrWhiteSpace(conversationId)) { if (!Guid.TryParse(conversationId, out var convId)) { await SseWriteAsync(http, new { action = "answer", data = "当前会话参数异常,请重新开始一次对话。" }, ct); await SseWriteAsync(http, new { action = "status", data = "error" }, ct); await http.Response.WriteAsync("data: [DONE]\n\n", ct); return; } requestedConversationId = convId; } var opened = await conversations.OpenAsync(userId.Value, requestedConversationId, parsedType, message, ct); if (!opened.Found) { await SseWriteAsync(http, new { action = "answer", data = "当前会话不存在或不属于当前账号,请重新开始一次对话。" }, ct); await SseWriteAsync(http, new { action = "status", data = "error" }, ct); await http.Response.WriteAsync("data: [DONE]\n\n", ct); return; } var activeConversationId = opened.ConversationId; if (opened.Created) await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct); // 附件解析(图片走 VLM、PDF 走 PdfPig),结果同时拼 LLM 上下文 + 持久化到 user message metadata var attachment = await attachments.BuildAsync(userId.Value, imageUrl, pdfUrl, ct); string? userMessageMetadataJson = null; if (attachment != null) { userMessageMetadataJson = JsonSerializer.Serialize(new { kind = attachment.Kind, category = attachment.Category, fileName = attachment.FileName, summary = attachment.Summary, imageUrl, pdfUrl, }, JsonOpts); await SseWriteAsync(http, new { action = "attachment", data = new { attachment.Kind, attachment.Category, attachment.Summary } }, ct); } await conversations.AddUserMessageAsync(activeConversationId, message, userMessageMetadataJson, ct); var urgentWarning = DetectUrgentRisk(message); if (!string.IsNullOrEmpty(urgentWarning)) { var urgentResponse = $""" {urgentWarning} 这种情况需要把安全放在第一位。请先停止自行判断和等待 AI 分析,尽快联系医生、互联网医院或前往急诊评估;如果症状正在加重,建议立即拨打当地急救电话。若身边有人,请让家人或同伴陪同,不要独自开车就医。 以上为 AI 安全提醒,不能替代医生诊断和治疗建议。 """; await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct); await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct); await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct); await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct); await SseWriteAsync(http, new { action = "status", data = "done" }, ct); await http.Response.WriteAsync("data: [DONE]\n\n", ct); return; } // 加载上下文 var systemPrompt = promptManager.GetSystemPrompt(parsedType); var patientContext = await patientContexts.BuildAsync(userId.Value, ct); // ── RAG 知识库检索 ── // 仅对需要医学知识的 Agent 启用检索;记数据/管用药/排运动这些纯工具调用类跳过。 // 失败/空结果不阻塞流程,仅作为 prompt 增强。 var knowledgeSnippet = ""; if (NeedsKnowledgeBase(parsedType) && knowledgeClient.IsEnabled) { knowledgeSnippet = await knowledgeClient.SearchAsync(message, ct); } var enhancedSystem = systemPrompt + "\n\n当前患者信息:\n" + patientContext; if (!string.IsNullOrWhiteSpace(knowledgeSnippet)) { enhancedSystem += "\n\n知识库参考资料(如与用户问题相关请优先采用):\n" + knowledgeSnippet; } var messages = new List { new() { Role = "system", Content = enhancedSystem }, }; // 加载历史对话(最近 12 条) var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct); foreach (var h in history) { var role = h.Role == MessageRole.User.ToString() ? "user" : "assistant"; var content = h.Content; // 历史 user message 如果带过附件,把附件摘要拼回 content,让 AI 能回忆起来 if (role == "user" && !string.IsNullOrWhiteSpace(h.MetadataJson)) { try { using var meta = JsonDocument.Parse(h.MetadataJson); if (meta.RootElement.TryGetProperty("summary", out var sumEl) && sumEl.ValueKind == JsonValueKind.String) { var sum = sumEl.GetString(); if (!string.IsNullOrWhiteSpace(sum) && // 跳过本轮:本轮 user message 会在下面单独拼 LlmContent,避免重复 h.Content != message) { content = $"[历史附件回忆:{sum}]\n{content}"; } } } catch (JsonException) { /* 忽略损坏的 metadata */ } } messages.Add(new ChatMessage { Role = role, Content = content }); } // 本轮如果带了附件,把完整识别结果拼到最后一条 user message 前 if (attachment != null && messages.Count > 0 && messages[^1].Role == "user") { messages[^1] = new ChatMessage { Role = "user", Content = $"{attachment.LlmContent}\n{messages[^1].Content}", }; } // Tool Calling 循环 var tools = GetToolsForAgent(parsedType); var maxIterations = 5; var fullResponse = ""; var completedNormally = false; var messageType = "text"; var metadata = new Dictionary(); for (int i = 0; i < maxIterations; i++) { await SseWriteAsync(http, new { action = "notice", message = i == 0 ? "正在分析..." : "正在处理..." }, ct); var response = await llmClient.ChatAsync(messages, tools: tools.Count > 0 ? tools : null, ct: ct); var choice = response.Choices?.FirstOrDefault(); if (choice == null) break; if (choice.FinishReason == "stop") { await foreach (var chunk in llmClient.ChatStreamAsync(messages, tools: null, ct: ct)) { try { var delta = JsonSerializer.Deserialize(chunk, JsonOpts); var content = delta?.Choices?.FirstOrDefault()?.Delta?.Content; if (!string.IsNullOrEmpty(content)) { fullResponse += content; await SseWriteAsync(http, new { action = "answer", data = content, type = messageType, metadata = metadata.Count > 0 ? metadata : null }, ct); } } catch (JsonException) { /* 跳过解析失败的 chunk */ } } completedNormally = true; break; } else if (choice.FinishReason == "tool_calls" && choice.Message?.ToolCalls != null) { messages.Add(new ChatMessage { Role = "assistant", Content = choice.Message.Content ?? "", ToolCalls = choice.Message.ToolCalls, }); foreach (var tc in choice.Message.ToolCalls) { object toolResult; try { toolResult = IsWriteToolCall(tc.Function.Name, tc.Function.Arguments) ? await PreparePendingWriteAsync(confirmations, userId.Value, tc.Function.Name, tc.Function.Arguments, ct) : await toolExecution.ExecuteAsync(tc.Function.Name, tc.Function.Arguments, userId.Value, ct); } catch (Exception ex) { toolResult = new { success = false, message = $"工具执行异常: {ex.Message}" }; } await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct); _UpdateMessageTypeAndMetadata(tc.Function.Name, toolResult, ref messageType, ref metadata); messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id }); } } else break; } // 保存 AI 回复 if (!string.IsNullOrEmpty(fullResponse)) { string? assistantMetadataJson = null; if (metadata.Count > 0 || messageType != "text") { metadata["messageType"] = messageType; assistantMetadataJson = JsonSerializer.Serialize(metadata, JsonOpts); } await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, assistantMetadataJson, ct); await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct); } await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct); await http.Response.WriteAsync("data: [DONE]\n\n", ct); }).RequireAuthorization(); app.MapPost("/api/ai/confirm-write/{commandId:guid}", async ( Guid commandId, HttpContext http, IAiToolExecutionService toolExecution, CancellationToken ct) => { var userId = GetUserId(http); if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401); var result = await toolExecution.ConfirmAsync(commandId, userId.Value, ct); return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message }); }).RequireAuthorization(); // 获取对话列表 app.MapGet("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) => { var userId = GetUserId(http); if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401); var result = await conversations.ListAsync(userId.Value, ct); return Results.Ok(new { code = 0, data = result, message = (string?)null }); }); // 获取对话历史 app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, CancellationToken ct) => { var userId = GetUserId(http); if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401); var messages = await conversations.GetMessagesAsync(userId.Value, id, ct); return Results.Ok(new { code = 0, data = messages, message = (string?)null }); }); // 删除对话 app.MapDelete("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, IAiConversationService conversations, CancellationToken ct) => { var userId = GetUserId(http); if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401); var deleted = await conversations.DeleteAsync(userId.Value, id, ct); return deleted ? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }) : Results.Json( new { code = 40401, data = (object?)null, message = "对话不存在" }, statusCode: StatusCodes.Status404NotFound); }).RequireAuthorization(); // 一键清空当前用户的全部对话 app.MapDelete("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) => { var userId = GetUserId(http); if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401); var count = await conversations.DeleteAllAsync(userId.Value, ct); return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null }); }); // 饮食分析页专用建议:不创建会话,不保存提示词或回复到历史记录。 app.MapPost("/api/ai/diet-commentary", async ( DietCommentaryRequest request, HttpContext http, DeepSeekClient llmClient, IPatientContextService patientContexts, CancellationToken ct) => { var userId = GetUserId(http); if (userId == null) return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401); var foods = request.Foods? .Where(food => !string.IsNullOrWhiteSpace(food.Name) && !string.IsNullOrWhiteSpace(food.Portion) && food.Calories > 0) .Take(20) .Select(food => new DietCommentaryFood(food.Name, food.Portion, food.Calories)) .ToList() ?? []; if (foods.Count == 0) return Results.Ok(new { code = 40001, data = (object?)null, message = "请先完善食物信息" }); var patientContext = await patientContexts.BuildAsync(userId.Value, ct); var foodDescription = DietCommentaryPolicy.BuildFoodDescription(foods); var response = await llmClient.ChatAsync( [ new ChatMessage { Role = "system", Content = DietCommentaryPolicy.SystemPrompt + "\n\n当前用户健康档案:\n" + patientContext, }, new ChatMessage { Role = "user", Content = "本餐食物:" + foodDescription }, ], maxTokens: 300, temperature: 0.3f, ct: ct); var commentary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim(); if (string.IsNullOrWhiteSpace(commentary)) return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" }); return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null }); }).RequireAuthorization(); app.MapPost("/api/ai/analyze-food-image", async ( HttpRequest httpRequest, HttpContext http, IDietImageAnalysisCoordinator dietAnalysis, CancellationToken ct) => { var userId = GetUserId(http); if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401); var form = await httpRequest.ReadFormAsync(ct); var files = form.Files.GetFiles("images"); var uploads = files .Select(file => new DietImageUploadFile(file.FileName, file.Length, file.OpenReadStream())) .ToList(); try { var result = await dietAnalysis.AnalyzeAsync(uploads, ct); return Results.Ok(new { code = result.Code, data = result.Data, message = result.Message }); } finally { foreach (var upload in uploads) await upload.Content.DisposeAsync(); } }); } // ── SSE / 认证辅助 ── private static async Task SseWriteAsync(HttpContext http, object data, CancellationToken ct) { var json = JsonSerializer.Serialize(data, JsonOpts); await http.Response.WriteAsync($"data: {json}\n\n", ct); await http.Response.Body.FlushAsync(ct); } private static Guid? GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : null; private static async Task TryUpdateConversationSummaryAsync( IAiConversationService conversations, DeepSeekClient llmClient, Guid userId, Guid conversationId, CancellationToken ct) { try { var messages = await conversations.GetMessagesAsync(userId, conversationId, ct); var transcript = BuildSummaryTranscript(messages); if (string.IsNullOrWhiteSpace(transcript)) return; var prompt = $""" 请把下面这次健康助手会话压缩成一个“对话记录标题”。 要求: - 只输出标题,不要解释 - 8到18个汉字 - 优先保留用户真正要解决的问题、对象、最终动作或结论 - 像列表标题,不要像完整句子 - 不要写“本次会话、用户咨询、健康建议、进行分析、相关问题” - 如果是上传图片/PDF,标题要体现附件内容和目的 示例: - 早餐热量识别 - 血压偏高记录 - 服药时间调整 - 报告指标解读 - 跑步计划打卡 会话内容: {transcript} """; var response = await llmClient.ChatAsync( [ new ChatMessage { Role = "system", Content = "你是对话标题生成助手,只输出短标题。" }, new ChatMessage { Role = "user", Content = prompt }, ], maxTokens: 120, temperature: 0.2f, ct: ct); var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim(); if (string.IsNullOrWhiteSpace(summary)) return; summary = Regex.Replace(summary, @"\s+", ""); summary = summary.Trim(' ', '。', '.', '"', '"', '\'', '“', '”', ':', ':'); summary = TrimSummaryPrefix(summary); await conversations.UpdateSummaryAsync(conversationId, summary, ct); } catch { // 摘要只是历史列表体验增强,失败不能影响主对话。 } } private static string BuildSummaryTranscript(IReadOnlyList messages) { var sb = new System.Text.StringBuilder(); foreach (var message in messages.TakeLast(24)) { var role = message.Role == MessageRole.User.ToString() ? "用户" : "AI"; var content = message.Content?.Trim() ?? ""; if (message.Role == MessageRole.User.ToString() && !string.IsNullOrWhiteSpace(message.MetadataJson)) { try { using var meta = JsonDocument.Parse(message.MetadataJson); if (meta.RootElement.TryGetProperty("summary", out var sumEl) && sumEl.ValueKind == JsonValueKind.String) { var attachmentSummary = sumEl.GetString(); if (!string.IsNullOrWhiteSpace(attachmentSummary)) content = $"[附件:{attachmentSummary}] {content}"; } } catch (JsonException) { } } if (string.IsNullOrWhiteSpace(content)) continue; if (content.Length > 800) content = content[..800] + "..."; sb.Append(role).Append(":").AppendLine(content); if (sb.Length > 6000) break; } return sb.ToString(); } private static string TrimSummaryPrefix(string summary) { var prefixes = new[] { "本次会话", "用户咨询", "用户询问", "健康建议", "相关问题", }; foreach (var prefix in prefixes) { if (summary.StartsWith(prefix, StringComparison.Ordinal)) return summary[prefix.Length..].Trim(' ', ':', ':', ',', ','); } return summary; } // ── Agent / Tool 调度 ── /// /// 哪些 Agent 需要走 FastGPT 知识库 RAG。 /// 纯工具调用类(Health/Medication/Exercise)跳过 RAG,避免拖慢响应。 /// private static bool NeedsKnowledgeBase(AgentType agentType) => agentType switch { AgentType.Default => true, AgentType.Consultation => true, AgentType.Diet => true, AgentType.Report => true, AgentType.Unified => true, _ => false, }; private static List GetToolsForAgent(AgentType agentType) => agentType switch { AgentType.Health => HealthDataAgentHandler.Tools, AgentType.Medication => MedicationAgentHandler.Tools, AgentType.Diet => DietAgentHandler.Tools, AgentType.Consultation => ConsultationAgentHandler.Tools, AgentType.Report => ReportAgentHandler.Tools, AgentType.Exercise => ExerciseAgentHandler.Tools, AgentType.Unified => [ HealthDataAgentHandler.RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool, MedicationAgentHandler.ManageMedicationTool, ExerciseAgentHandler.ManageExerciseTool, CommonAgentHandler.CheckArchiveTool, ], _ => CommonAgentHandler.Tools, }; private static bool IsWriteToolCall(string toolName, string arguments) { if (toolName == "record_health_data") return true; if (toolName == "manage_archive") return GetToolAction(arguments) != "query"; if (toolName == "manage_medication") return GetToolAction(arguments) is "create" or "confirm"; if (toolName == "manage_exercise") return GetToolAction(arguments) is "create" or "checkin"; return false; } private static string GetToolAction(string arguments) { try { using var json = JsonDocument.Parse(arguments); return json.RootElement.TryGetProperty("action", out var action) ? action.GetString()?.ToLowerInvariant() ?? "" : ""; } catch { return ""; } } private static async Task PreparePendingWriteAsync( IAiWriteConfirmationStore confirmations, Guid userId, string toolName, string arguments, CancellationToken ct) { var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct); using var json = JsonDocument.Parse(arguments); var args = json.RootElement; var preview = new Dictionary { ["success"] = true, ["pendingConfirmation"] = true, ["confirmationId"] = command.Id, ["message"] = "等待用户确认后写入", }; switch (toolName) { case "record_health_data": AddHealthPreview(preview, args); break; case "manage_medication": preview["type"] = "medication"; preview["name"] = GetString(args, "name") ?? "服药确认"; preview["dosage"] = GetString(args, "dosage") ?? ""; preview["frequency"] = GetString(args, "frequency") ?? "Daily"; preview["time"] = GetStringArray(args, "time_of_day"); preview["duration_days"] = GetInt(args, "duration_days") ?? 0; preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd"); break; case "manage_exercise": preview["type"] = "exercise"; preview["exercise_type"] = GetString(args, "exercise_type") ?? "运动"; preview["duration_minutes"] = GetInt(args, "duration_minutes") ?? 30; preview["day_count"] = GetInt(args, "duration_days") ?? 7; preview["start_date"] = GetString(args, "start_date") ?? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).ToString("yyyy-MM-dd"); preview["reminder_time"] = GetString(args, "reminder_time") ?? "19:00"; break; case "manage_archive": preview["type"] = "archive"; preview["value"] = "健康档案更新"; preview["unit"] = ""; break; } return preview; } private static void AddHealthPreview(Dictionary preview, JsonElement args) { var type = GetString(args, "type") ?? ""; switch (type) { case "blood_pressure": var systolic = GetInt(args, "systolic"); var diastolic = GetInt(args, "diastolic"); preview["type"] = HealthMetricType.BloodPressure.ToString(); preview["value"] = $"{systolic}/{diastolic}"; preview["unit"] = "mmHg"; preview["isAbnormal"] = systolic >= 140 || diastolic >= 90 || systolic <= 89 || diastolic <= 59; break; case "heart_rate": AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60); break; case "glucose": AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v > 6.1m || v < 3.9m); break; case "spo2": AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94); break; case "weight": AddMetricPreview(preview, HealthMetricType.Weight, GetDecimal(args, "weight"), "kg", _ => false); break; } } private static void AddMetricPreview( Dictionary preview, HealthMetricType type, decimal? value, string unit, Func isAbnormal) { preview["type"] = type.ToString(); preview["value"] = value?.ToString() ?? ""; preview["unit"] = unit; preview["isAbnormal"] = value.HasValue && isAbnormal(value.Value); } private static string? GetString(JsonElement element, string name) => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; private static int? GetInt(JsonElement element, string name) => element.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : null; private static decimal? GetDecimal(JsonElement element, string name) => element.TryGetProperty(name, out var value) && value.TryGetDecimal(out var result) ? result : null; private static string GetStringArray(JsonElement element, string name) => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Array ? string.Join(", ", value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x))) : ""; private static string? DetectUrgentRisk(string message) { var text = message.Trim(); if (string.IsNullOrEmpty(text)) return null; if (ContainsAny(text, "剧烈胸痛", "胸口剧痛", "胸痛明显", "胸闷憋气", "喘不上气", "呼吸困难", "呼吸很困难", "意识模糊", "昏厥", "晕厥", "说话不清", "口角歪斜", "一侧无力")) return "你描述的症状可能属于需要紧急评估的危险信号。"; var bp = Regex.Match(text, @"(?:血压|bp|BP)?\s*(?\d{2,3})\s*/\s*(?\d{2,3})"); if (bp.Success && int.TryParse(bp.Groups["sys"].Value, out var systolic) && int.TryParse(bp.Groups["dia"].Value, out var diastolic) && (systolic >= 180 || diastolic >= 120)) { return $"你提到的血压 {systolic}/{diastolic} mmHg 已达到明显危险范围。"; } var spo2 = MatchMetricValue(text, "血氧|氧饱和|SpO2|spo2"); if (spo2.HasValue && spo2.Value <= 90) return $"你提到的血氧 {spo2.Value:0.#}% 明显偏低。"; var glucose = MatchMetricValue(text, "血糖|葡萄糖|glucose"); if (glucose.HasValue && (glucose.Value >= 16.7m || glucose.Value <= 3.0m)) return $"你提到的血糖 {glucose.Value:0.#} mmol/L 属于需要尽快处理的异常范围。"; var heartRate = MatchMetricValue(text, "心率|脉搏|heart rate|HR|hr"); if (heartRate.HasValue && (heartRate.Value >= 130 || heartRate.Value <= 45)) return $"你提到的心率 {heartRate.Value:0.#} 次/分属于明显异常范围。"; return null; } private static bool ContainsAny(string text, params string[] keywords) => keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase)); private static decimal? MatchMetricValue(string text, string metricPattern) { var match = Regex.Match(text, $@"(?:{metricPattern})[^\d]{{0,8}}(?\d{{1,3}}(?:\.\d+)?)", RegexOptions.IgnoreCase); return match.Success && decimal.TryParse(match.Groups["value"].Value, out var value) ? value : null; } // ── JSON 类型安全转换 ── private static int _ToInt(object? v) => v switch { JsonElement je => je.ValueKind == JsonValueKind.Number ? je.GetInt32() : (int.TryParse(je.GetRawText().Trim('"'), out var i) ? i : 0), int i => i, long l => (int)l, string s => int.TryParse(s, out var si) ? si : 0, _ => 0 }; private static bool _ToBool(object? v) => v switch { JsonElement je => je.ValueKind == JsonValueKind.True || je.ValueKind == JsonValueKind.False ? je.GetBoolean() : false, bool b => b, _ => false }; // ── 消息类型判断 ── private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary metadata) { // 将匿名对象统一转成 Dictionary(toolResult 可能是匿名类型,不是 IDictionary) var resultDict = toolResult as IDictionary; if (resultDict == null) { try { var json = JsonSerializer.Serialize(toolResult, JsonOpts); resultDict = JsonSerializer.Deserialize>(json, JsonOpts); } catch (JsonException) { resultDict = new Dictionary(); } } var isPendingConfirmation = resultDict != null && resultDict.TryGetValue("pendingConfirmation", out var pendingValue) && _ToBool(pendingValue); if (isPendingConfirmation && resultDict!.TryGetValue("confirmationId", out var confirmationIdValue)) { var confirmationId = confirmationIdValue?.ToString()?.Trim('"'); if (!string.IsNullOrWhiteSpace(confirmationId)) { if (!metadata.TryGetValue("confirmationIds", out var idsValue) || idsValue is not List ids) { ids = []; metadata["confirmationIds"] = ids; } ids.Add(confirmationId); } } switch (toolName) { case "record_health_data": if (!isPendingConfirmation) break; messageType = "data_confirm"; if (resultDict != null) { if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type?.ToString() ?? ""; if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val?.ToString() ?? ""; if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit?.ToString() ?? ""; if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = _ToBool(abn); if (resultDict.TryGetValue("success", out var success)) metadata["success"] = _ToBool(success); metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); } break; case "manage_medication": if (!isPendingConfirmation) break; messageType = "medication_confirm"; if (resultDict != null) { if (resultDict.TryGetValue("name", out var name)) metadata["name"] = name?.ToString() ?? ""; if (resultDict.TryGetValue("dosage", out var dosage)) metadata["dosage"] = dosage?.ToString() ?? ""; if (resultDict.TryGetValue("time", out var time)) metadata["time"] = time?.ToString() ?? ""; if (resultDict.TryGetValue("frequency", out var freq)) metadata["frequency"] = freq?.ToString() ?? ""; if (resultDict.TryGetValue("duration_days", out var dd2)) metadata["duration_days"] = _ToInt(dd2); if (resultDict.TryGetValue("start_date", out var sd)) metadata["startDate"] = sd?.ToString() ?? ""; } break; case "manage_exercise": if (!isPendingConfirmation) break; messageType = "data_confirm"; if (resultDict != null) { metadata["type"] = "exercise"; resultDict.TryGetValue("exercise_type", out var et); metadata["value"] = et?.ToString() ?? ""; resultDict.TryGetValue("duration_minutes", out var dm); metadata["unit"] = dm != null ? $"每天{dm}分钟" : ""; resultDict.TryGetValue("day_count", out var dc); if (dc != null) metadata["durationDays"] = dc.ToString() ?? ""; resultDict.TryGetValue("success", out var ok); metadata["success"] = _ToBool(ok); metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); } break; case "manage_archive": if (!isPendingConfirmation) break; messageType = "data_confirm"; metadata["type"] = "archive"; metadata["value"] = "健康档案更新"; metadata["unit"] = ""; metadata["success"] = true; metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); break; case "analyze_report": messageType = "report_analysis"; break; } } } public sealed record DietCommentaryFoodRequest(string Name, string Portion, int Calories); public sealed record DietCommentaryRequest(IReadOnlyList? Foods);