feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化

- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
This commit is contained in:
MingNian
2026-06-20 20:41:42 +08:00
parent c610417e29
commit 4d213b5a44
132 changed files with 6733 additions and 2856 deletions

View File

@@ -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);