feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
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;
|
||||
|
||||
@@ -26,10 +31,12 @@ public static class AiChatEndpoints
|
||||
string token,
|
||||
string agentType,
|
||||
HttpContext http,
|
||||
AppDbContext db,
|
||||
DeepSeekClient llmClient,
|
||||
PromptManager promptManager,
|
||||
VisionClient visionClient,
|
||||
IAiToolExecutionService toolExecution,
|
||||
IAiWriteConfirmationStore confirmations,
|
||||
IAiConversationService conversations,
|
||||
IPatientContextService patientContexts,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// 支持 token 通过 query string(浏览器 EventSource)或 header 传递
|
||||
@@ -51,38 +58,57 @@ public static class AiChatEndpoints
|
||||
http.Response.Headers.Connection = "keep-alive";
|
||||
http.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
|
||||
// 创建或获取对话
|
||||
Conversation? conversation = null;
|
||||
if (!string.IsNullOrEmpty(conversationId) && Guid.TryParse(conversationId, out var convId))
|
||||
conversation = await db.Conversations.FindAsync([convId], ct);
|
||||
|
||||
if (conversation == null)
|
||||
// 创建或获取对话。传入 conversationId 时必须校验归属,避免多账号切换或缓存异常导致串号。
|
||||
Guid? requestedConversationId = null;
|
||||
if (!string.IsNullOrWhiteSpace(conversationId))
|
||||
{
|
||||
conversation = new Conversation
|
||||
if (!Guid.TryParse(conversationId, out var convId))
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId.Value, AgentType = parsedType,
|
||||
Title = message.Length > 30 ? message[..30] : message,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Conversations.Add(conversation);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await SseWriteAsync(http, new { action = "conversation_id", data = conversation.Id.ToString() }, ct);
|
||||
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 userMsg = new ConversationMessage
|
||||
var opened = await conversations.OpenAsync(userId.Value, requestedConversationId, parsedType, message, ct);
|
||||
if (!opened.Found)
|
||||
{
|
||||
Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.User,
|
||||
Content = message, CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.ConversationMessages.Add(userMsg);
|
||||
conversation.MessageCount++;
|
||||
conversation.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
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);
|
||||
|
||||
await conversations.AddUserMessageAsync(activeConversationId, message, ct);
|
||||
|
||||
var urgentWarning = DetectUrgentRisk(message);
|
||||
if (!string.IsNullOrEmpty(urgentWarning))
|
||||
{
|
||||
var urgentResponse = $"""
|
||||
{urgentWarning}
|
||||
|
||||
这种情况需要把安全放在第一位。请先停止自行判断和等待 AI 分析,尽快联系医生、互联网医院或前往急诊评估;如果症状正在加重,建议立即拨打当地急救电话。若身边有人,请让家人或同伴陪同,不要独自开车就医。
|
||||
|
||||
以上为 AI 安全提醒,不能替代医生诊断和治疗建议。
|
||||
""";
|
||||
|
||||
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, 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 BuildPatientContext(db, userId.Value, ct);
|
||||
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
|
||||
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
@@ -90,17 +116,13 @@ public static class AiChatEndpoints
|
||||
};
|
||||
|
||||
// 加载历史对话(最近 10 条)
|
||||
var history = await db.ConversationMessages
|
||||
.Where(m => m.ConversationId == conversation.Id)
|
||||
.OrderByDescending(m => m.CreatedAt)
|
||||
.Take(12)
|
||||
.ToListAsync(ct);
|
||||
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
|
||||
|
||||
foreach (var h in history.Reverse<ConversationMessage>())
|
||||
foreach (var h in history)
|
||||
{
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = h.Role == MessageRole.User ? "user" : "assistant",
|
||||
Role = h.Role == MessageRole.User.ToString() ? "user" : "assistant",
|
||||
Content = h.Content,
|
||||
});
|
||||
}
|
||||
@@ -155,7 +177,9 @@ public static class AiChatEndpoints
|
||||
object toolResult;
|
||||
try
|
||||
{
|
||||
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value, visionClient);
|
||||
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)
|
||||
{
|
||||
@@ -173,71 +197,62 @@ public static class AiChatEndpoints
|
||||
|
||||
// 保存 AI 回复
|
||||
if (!string.IsNullOrEmpty(fullResponse))
|
||||
{
|
||||
db.ConversationMessages.Add(new ConversationMessage
|
||||
{
|
||||
Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = MessageRole.Assistant,
|
||||
Content = fullResponse, CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
conversation.MessageCount++;
|
||||
conversation.Summary = fullResponse.Length > 100 ? fullResponse[..100] : fullResponse;
|
||||
conversation.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct);
|
||||
|
||||
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
||||
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||
});
|
||||
|
||||
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, AppDbContext db, CancellationToken ct) =>
|
||||
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 conversations = await db.Conversations
|
||||
.Where(c => c.UserId == userId.Value)
|
||||
.OrderByDescending(c => c.UpdatedAt)
|
||||
.Select(c => new { c.Id, AgentType = c.AgentType.ToString(), c.Title, c.Summary, c.MessageCount, c.CreatedAt, c.UpdatedAt })
|
||||
.ToListAsync(ct);
|
||||
var result = await conversations.ListAsync(userId.Value, ct);
|
||||
|
||||
return Results.Ok(new { code = 0, data = conversations, message = (string?)null });
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
});
|
||||
|
||||
// 获取对话历史
|
||||
app.MapGet("/api/ai/conversations/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
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 db.ConversationMessages
|
||||
.Where(m => m.ConversationId == id && m.Conversation.UserId == userId.Value)
|
||||
.OrderBy(m => m.CreatedAt)
|
||||
.Select(m => new { m.Id, Role = m.Role.ToString(), m.Content, m.Intent, m.MetadataJson, m.CreatedAt })
|
||||
.ToListAsync(ct);
|
||||
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, AppDbContext db, CancellationToken ct) =>
|
||||
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 conv = await db.Conversations.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId.Value, ct);
|
||||
if (conv != null)
|
||||
{
|
||||
db.Conversations.Remove(conv);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
await conversations.DeleteAsync(userId.Value, id, ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
|
||||
// VLM 食物识别
|
||||
app.MapPost("/api/ai/analyze-food-image", async (
|
||||
HttpRequest httpRequest, HttpContext http,
|
||||
VisionClient visionClient, AppDbContext db,
|
||||
HttpRequest httpRequest,
|
||||
HttpContext http,
|
||||
IDietImageAnalysisCoordinator dietAnalysis,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
@@ -245,61 +260,19 @@ public static class AiChatEndpoints
|
||||
|
||||
var form = await httpRequest.ReadFormAsync(ct);
|
||||
var files = form.Files.GetFiles("images");
|
||||
if (files == null || files.Count == 0)
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" });
|
||||
|
||||
var imageUrls = new List<string>();
|
||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||
Directory.CreateDirectory(uploadsDir);
|
||||
|
||||
foreach (var file in files)
|
||||
var uploads = files
|
||||
.Select(file => new DietImageUploadFile(file.FileName, file.Length, file.OpenReadStream()))
|
||||
.ToList();
|
||||
try
|
||||
{
|
||||
if (file.Length > 20 * 1024 * 1024)
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "文件大小超过 20MB 限制" });
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (ext is not ".jpg" and not ".jpeg" and not ".png" and not ".heic")
|
||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "不支持的图片格式,仅支持 JPG/PNG/HEIC" });
|
||||
|
||||
var safeName = $"{Guid.NewGuid()}_{Path.GetFileName(file.FileName)}";
|
||||
var filePath = Path.Combine(uploadsDir, safeName);
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
await file.CopyToAsync(stream, ct);
|
||||
|
||||
// 千问3.7-plus + vl_high_resolution_images=true 支持到 16M 像素,保留原图细节
|
||||
// base64 ≥ 7MB(官方限制)时才压缩,否则原图上传
|
||||
var fileBytes = await File.ReadAllBytesAsync(filePath, ct);
|
||||
var base64 = Convert.ToBase64String(fileBytes);
|
||||
if (base64.Length > 7 * 1024 * 1024)
|
||||
{
|
||||
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
|
||||
CompressImage(filePath, compressedPath, maxWidth: 2048, quality: 85L);
|
||||
fileBytes = await File.ReadAllBytesAsync(compressedPath, ct);
|
||||
base64 = Convert.ToBase64String(fileBytes);
|
||||
}
|
||||
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
||||
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();
|
||||
}
|
||||
|
||||
var prompt = """
|
||||
识别图片中的食物和饮品,返回JSON数组:
|
||||
[{"name":"名称","portion":"份量","calories":热量}]
|
||||
只返回JSON,不要其他内容。
|
||||
""";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
|
||||
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||||
// 记录VLM原始返回用于排查
|
||||
var uploadsDir2 = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||
var logPath = Path.Combine(uploadsDir2, $"vlm_log_{DateTime.UtcNow:HHmmss}.txt");
|
||||
await File.WriteAllTextAsync(logPath, $"MODEL: {Environment.GetEnvironmentVariable("VLM_MODEL")}\nIMAGE_SIZE: {imageUrls.FirstOrDefault()?.Length ?? 0}\nRESPONSE:\n{result}", ct);
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.Ok(new { code = 50001, data = (object?)null, message = $"食物识别失败:{ex.Message}" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -348,60 +321,176 @@ public static class AiChatEndpoints
|
||||
_ => CommonAgentHandler.Tools,
|
||||
};
|
||||
|
||||
private static Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId, VisionClient? visionClient = null)
|
||||
private static bool IsWriteToolCall(string toolName, string arguments)
|
||||
{
|
||||
using var jsonDoc = JsonDocument.Parse(arguments);
|
||||
var root = jsonDoc.RootElement;
|
||||
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;
|
||||
}
|
||||
|
||||
return toolName switch
|
||||
private static string GetToolAction(string arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, db, userId),
|
||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
|
||||
"analyze_report" => visionClient != null
|
||||
? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient)
|
||||
: Task.FromResult<object>(new { success = false, message = "VLM服务未配置" }),
|
||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId),
|
||||
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root),
|
||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
||||
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 async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
|
||||
private static void AddHealthPreview(Dictionary<string, object?> preview, JsonElement args)
|
||||
{
|
||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
||||
var recentRecords = await db.HealthRecords.Where(r => r.UserId == userId)
|
||||
.OrderByDescending(r => r.RecordedAt).Take(10).ToListAsync(ct);
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
if (archive != null)
|
||||
var type = GetString(args, "type") ?? "";
|
||||
switch (type)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
||||
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
||||
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
|
||||
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
|
||||
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 >= 7.0m || v <= 3.8m);
|
||||
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;
|
||||
}
|
||||
if (recentRecords.Count > 0)
|
||||
{
|
||||
sb.AppendLine("近期健康数据:");
|
||||
foreach (var r in recentRecords)
|
||||
sb.AppendLine($" {r.MetricType}: {RecordValue(r)} ({r.RecordedAt:MM-dd HH:mm})");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RecordValue(HealthRecord r) => r.MetricType switch
|
||||
private static void AddMetricPreview(
|
||||
Dictionary<string, object?> preview,
|
||||
HealthMetricType type,
|
||||
decimal? value,
|
||||
string unit,
|
||||
Func<decimal, bool> isAbnormal)
|
||||
{
|
||||
HealthMetricType.BloodPressure => $"{r.Systolic}/{r.Diastolic}",
|
||||
HealthMetricType.HeartRate => $"{r.Value}次/分",
|
||||
HealthMetricType.Glucose => $"{r.Value}",
|
||||
HealthMetricType.SpO2 => $"{r.Value}%",
|
||||
HealthMetricType.Weight => $"{r.Value}kg",
|
||||
_ => "—"
|
||||
};
|
||||
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
|
||||
@@ -435,39 +524,59 @@ public static class AiChatEndpoints
|
||||
catch { 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"] = abn is bool b2 ? b2 : false;
|
||||
if (resultDict.TryGetValue("success", out var success)) metadata["success"] = success is bool b && b;
|
||||
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();
|
||||
metadata["name"] = name?.ToString() ?? "";
|
||||
if (resultDict.TryGetValue("dosage", out var dosage))
|
||||
metadata["dosage"] = dosage.ToString();
|
||||
metadata["dosage"] = dosage?.ToString() ?? "";
|
||||
if (resultDict.TryGetValue("time", out var time))
|
||||
metadata["time"] = time.ToString();
|
||||
metadata["time"] = time?.ToString() ?? "";
|
||||
if (resultDict.TryGetValue("frequency", out var freq))
|
||||
metadata["frequency"] = freq.ToString();
|
||||
if (resultDict.TryGetValue("duration_days", out var dd2) && dd2 is int d2)
|
||||
metadata["duration_days"] = d2;
|
||||
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();
|
||||
metadata["startDate"] = sd?.ToString() ?? "";
|
||||
}
|
||||
break;
|
||||
case "manage_exercise":
|
||||
if (!isPendingConfirmation) break;
|
||||
messageType = "data_confirm";
|
||||
if (resultDict != null)
|
||||
{
|
||||
@@ -477,40 +586,27 @@ public static class AiChatEndpoints
|
||||
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();
|
||||
if (dc != null) metadata["durationDays"] = dc.ToString() ?? "";
|
||||
resultDict.TryGetValue("success", out var ok);
|
||||
metadata["success"] = ok?.ToString() == "True";
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 图片处理 ──
|
||||
|
||||
private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality)
|
||||
{
|
||||
using var image = Image.FromFile(inputPath);
|
||||
var width = image.Width;
|
||||
var height = image.Height;
|
||||
if (width > maxWidth)
|
||||
{
|
||||
height = (int)((double)height / width * maxWidth);
|
||||
width = maxWidth;
|
||||
}
|
||||
using var bitmap = new Bitmap(width, height);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
|
||||
graphics.DrawImage(image, 0, 0, width, height);
|
||||
|
||||
var jpegCodec = ImageCodecInfo.GetImageEncoders().First(c => c.MimeType == "image/jpeg");
|
||||
var parameters = new EncoderParameters(1);
|
||||
parameters.Param[0] = new EncoderParameter(Encoder.Quality, quality);
|
||||
bitmap.Save(outputPath, jpegCodec, parameters);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ChatRequest(string Message, string? ConversationId);
|
||||
|
||||
Reference in New Issue
Block a user