Files
AI-Health/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs
MingNian 9cea41705e feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构
## 后端安全加固
- 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越
- LocalReportFileStorage: 文件存储路径安全加固
- local_account_file_cleanup: 账号删除时文件清理逻辑增强
- AuthService: 认证逻辑增强
- file_endpoints / report_endpoints: 文件访问接口安全加固
- ai_chat_endpoints / doctor_endpoints: 接口安全调整
- Program.cs: 服务注册调整

## 前端认证与媒体
- 新增 authenticated_network_image.dart: 带认证的图片加载组件
- auth_provider: 认证状态管理大幅增强(+173)
- api_client: 网络客户端增强(+124)
- chat_provider: 聊天 provider 重构(+76)
- omron_device_provider: 蓝牙设备 provider 增强(+53)
- sse_handler: SSE 处理增强(+35)
- consultation_provider / data_providers / conversation_history_provider: 调整

## 页面调整
- remaining_pages: 健康档案/饮食记录等页面增强(+115)
- home_page / chat_messages_view: 主页微调
- doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports)
- report_pages / settings_pages / notification_prefs_page: 微调
- device_scan_page / diet_capture_page / admin_home_page: 微调

## 测试
- 新增 file_path_security_tests: 文件路径安全测试
- 新增 protected_media_url_test: 媒体URL保护测试
- 新增 user_session_identity_test: 用户会话身份测试
- account_deletion_tests / application_service_tests / auth_tests: 更新
2026-07-20 10:19:01 +08:00

856 lines
39 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 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;
/// <summary>
/// AI 对话 SSE 端点——支持 7 个 Agent
/// </summary>
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>(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<ChatMessage>
{
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<string, object>();
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<ChatCompletionResponse>(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<AiConversationMessageDto> 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 调度 ──
/// <summary>
/// 哪些 Agent 需要走 FastGPT 知识库 RAG。
/// 纯工具调用类Health/Medication/Exercise跳过 RAG避免拖慢响应。
/// </summary>
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<ToolDefinition> 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<object> 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<string, object?>
{
["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<string, object?> 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<string, object?> preview,
HealthMetricType type,
decimal? value,
string unit,
Func<decimal, bool> 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*(?<sys>\d{2,3})\s*/\s*(?<dia>\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}}(?<value>\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<string, object> metadata)
{
// 将匿名对象统一转成 DictionarytoolResult 可能是匿名类型,不是 IDictionary
var resultDict = toolResult as IDictionary<string, object>;
if (resultDict == null)
{
try
{
var json = JsonSerializer.Serialize(toolResult, JsonOpts);
resultDict = JsonSerializer.Deserialize<Dictionary<string, object>>(json, JsonOpts);
}
catch (JsonException) { resultDict = new Dictionary<string, object>(); }
}
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<string> 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<DietCommentaryFoodRequest>? Foods);