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, IMedicationService medications, 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 beijingNow = DateTime.UtcNow.AddHours(8); var enhancedSystem = systemPrompt + $"\n\n系统当前北京时间:{beijingNow:yyyy-MM-dd HH:mm:ss}(UTC+8)。所有‘今天/昨天/当前/下一次’均以此时间为准。" + "\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, medications, 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, PatientReadAgentHandler.QueryDietRecordsTool, PatientReadAgentHandler.QueryFollowUpsTool, PatientReadAgentHandler.QueryReportsTool, PatientReadAgentHandler.QueryNotificationsTool, ], _ => CommonAgentHandler.Tools, }; private static bool IsWriteToolCall(string toolName, string arguments) { if (toolName == "record_health_data") return true; 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, IMedicationService medications, Guid userId, string toolName, string arguments, CancellationToken ct) { using var json = JsonDocument.Parse(arguments); var args = json.RootElement; AiMedicationPlanDto? medicationPlan = null; if (toolName == "manage_medication") { var validationError = MedicationAgentHandler.ValidateWriteArguments(args); if (validationError != null) return new { success = false, message = validationError }; if (string.Equals(GetString(args, "action"), "confirm", StringComparison.OrdinalIgnoreCase)) { var medicationId = args.GetProperty("medication_id").GetGuid(); var scheduledTime = GetString(args, "scheduled_time")!; var overview = await medications.GetAiOverviewAsync( userId, DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct); medicationPlan = overview.FirstOrDefault(plan => plan.Id == medicationId && plan.ScheduledOnDate && plan.Doses.Any(dose => dose.ScheduledTime == scheduledTime && dose.Status is "scheduled" or "overdue")); if (medicationPlan == null) return new { success = false, message = "没有找到这顿尚未确认的今日服药安排,请重新查询后再确认" }; } } var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct); 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"; var medicationAction = GetString(args, "action"); preview["name"] = medicationPlan?.Name ?? GetString(args, "name") ?? "用药计划"; preview["operation"] = medicationAction ?? "create"; preview["dosage"] = medicationPlan?.Dosage ?? GetString(args, "dosage") ?? ""; preview["frequency"] = medicationPlan?.Frequency ?? GetString(args, "frequency") ?? ""; preview["time"] = GetString(args, "scheduled_time") ?? GetStringArray(args, "time_of_day"); preview["duration_days"] = GetInt(args, "duration_days") ?? 0; preview["long_term"] = args.TryGetProperty("long_term", out var longTerm) && longTerm.ValueKind == JsonValueKind.True; preview["start_date"] = medicationPlan?.StartDate?.ToString("yyyy-MM-dd") ?? GetString(args, "start_date") ?? ""; 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; } 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("operation", out var operation)) metadata["operation"] = operation?.ToString() ?? "create"; 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 "analyze_report": messageType = "report_analysis"; break; } } } public sealed record DietCommentaryFoodRequest(string Name, string Portion, int Calories); public sealed record DietCommentaryRequest(IReadOnlyList? Foods);