880 lines
40 KiB
C#
880 lines
40 KiB
C#
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,
|
||
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>(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<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, 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<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,
|
||
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<object> 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<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";
|
||
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<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)
|
||
{
|
||
// 将匿名对象统一转成 Dictionary(toolResult 可能是匿名类型,不是 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("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<DietCommentaryFoodRequest>? Foods);
|