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,3 +1,6 @@
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
@@ -21,38 +24,33 @@ public static class CommonAgentHandler
public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
public static async Task<object> Execute(
string toolName,
JsonElement args,
Guid userId,
IHealthRecordService healthRecords,
IHealthArchiveService archives,
CancellationToken ct = default)
{
return toolName switch
{
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, args),
"check_archive" => await ExecuteCheckArchive(db, userId),
"query_health_records" => await ExecuteQueryHealthRecords(healthRecords, userId, args, ct),
"check_archive" => await ExecuteCheckArchive(archives, userId, ct),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args)
private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
var query = db.HealthRecords.Where(r => r.UserId == userId);
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
query = query.Where(r => r.MetricType == mt);
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days));
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
{
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt,
}).ToListAsync();
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
return new { count = records.Count, records };
}
private static async Task<object> ExecuteCheckArchive(AppDbContext db, Guid userId)
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
{
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId);
var archive = await archives.GetAsync(userId, ct);
if (archive == null) return new { found = false };
return new
{
@@ -85,49 +83,26 @@ public static class CommonAgentHandler
}
};
public static async Task<object> ExecuteManageArchive(AppDbContext db, Guid userId, JsonElement args)
public static Task<object> ExecuteManageArchive(
IHealthArchiveService archives,
Guid userId,
JsonElement args,
CancellationToken ct = default)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
var archive = await db.HealthArchives.FirstOrDefaultAsync(x => x.UserId == userId);
if (archive == null)
{
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
db.HealthArchives.Add(archive);
}
switch (action)
{
case "update_diagnosis":
archive.Diagnosis = args.TryGetProperty("diagnosis", out var d) ? d.GetString() : archive.Diagnosis;
break;
case "update_surgery":
archive.SurgeryType = args.TryGetProperty("surgery_type", out var st) ? st.GetString() : archive.SurgeryType;
if (args.TryGetProperty("surgery_date", out var sd))
archive.SurgeryDate = DateOnly.TryParse(sd.GetString(), out var date) ? date : archive.SurgeryDate;
break;
case "update_allergies":
if (args.TryGetProperty("allergies", out var al) && al.ValueKind == JsonValueKind.Array)
archive.Allergies = [.. al.EnumerateArray().Select(x => x.GetString()!)];
break;
case "update_chronic_diseases":
if (args.TryGetProperty("chronic_diseases", out var cd) && cd.ValueKind == JsonValueKind.Array)
archive.ChronicDiseases = [.. cd.EnumerateArray().Select(x => x.GetString()!)];
break;
case "update_diet_restrictions":
if (args.TryGetProperty("diet_restrictions", out var dr) && dr.ValueKind == JsonValueKind.Array)
archive.DietRestrictions = [.. dr.EnumerateArray().Select(x => x.GetString()!)];
break;
default: // query
return new
{
found = true, archive.Diagnosis, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
};
}
archive.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
var request = new HealthArchiveUpdateRequest(
args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
ReadStringArray(args, "allergies"),
ReadStringArray(args, "diet_restrictions"),
ReadStringArray(args, "chronic_diseases"),
args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
return archives.ExecuteAiActionAsync(userId, action, request, ct);
}
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) =>
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
: null;
}

View File

@@ -7,12 +7,4 @@ public static class ConsultationAgentHandler
{
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" or "check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
}

View File

@@ -7,12 +7,4 @@ public static class DietAgentHandler
{
public static List<ToolDefinition> Tools => [CommonAgentHandler.CheckArchiveTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
}

View File

@@ -1,85 +1,83 @@
using Health.Application.Exercises;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 运动计划 Agent 工具处理器
/// </summary>
public static class ExerciseAgentHandler
{
public static readonly ToolDefinition ManageExerciseTool = new()
{
Function = new()
{
Name = "manage_exercise", Description = "运动计划管理(创建/查询/打卡)",
Parameters = new { type = "object", properties = new {
action = new { type = "string", description = "create/query/checkin" },
week_start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
items = new { type = "array", description = "每天的运动项目", items = new { type = "object", properties = new {
day_of_week = new { type = "integer", description = "0=周日 1=周一...6=周六" },
exercise_type = new { type = "string", description = "运动类型,如散步/慢跑/游泳/太极" },
duration_minutes = new { type = "integer", description = "时长(分钟)" },
is_rest_day = new { type = "boolean", description = "是否休息日" }
}}}
}, required = new[] { "action" } }
}
Name = "manage_exercise",
Description = "运动计划管理(创建/查询/打卡)",
Parameters = new
{
type = "object",
properties = new
{
action = new { type = "string", description = "create/query/checkin" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd默认今天" },
duration_days = new { type = "integer", description = "连续运动天数如一周为7天" },
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
duration_minutes = new { type = "integer", description = "每天运动时长,单位分钟" },
reminder_time = new { type = "string", description = "每天提醒时间 HH:mm默认19:00" },
item_id = new { type = "string", description = "打卡条目 ID" },
},
required = new[] { "action" },
},
},
};
public static List<ToolDefinition> Tools => [ManageExerciseTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
public static async Task<object> Execute(
string toolName,
JsonElement args,
AppDbContext db,
Guid userId,
IExerciseService? exercises = null,
CancellationToken ct = default)
{
return toolName switch
if (toolName != "manage_exercise" || exercises == null)
return new { success = false, message = $"未知工具: {toolName}" };
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query";
if (action == "create")
{
"manage_exercise" => await ExecuteManageExercise(db, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
switch (action)
{
case "create":
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
if (args.TryGetProperty("items", out var items))
{
foreach (var item in items.EnumerateArray())
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(),
ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步",
DurationMinutes = item.GetProperty("duration_minutes").GetInt32(),
IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(),
});
}
}
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync();
var firstItem = plan.Items.FirstOrDefault();
return new {
success = true, plan_id = plan.Id,
exercise_type = firstItem?.ExerciseType ?? "运动",
duration_minutes = firstItem?.DurationMinutes ?? 30,
day_count = plan.Items.Count,
};
case "checkin":
var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty;
var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]);
if (exerciseItem == null) return new { success = false, message = "条目不存在" };
exerciseItem.IsCompleted = true;
exerciseItem.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
default:
var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId)
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
if (existingPlan == null) return new { found = false };
var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
? parsedStart
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var days = args.TryGetProperty("duration_days", out var daysValue) && daysValue.TryGetInt32(out var parsedDays)
? Math.Clamp(parsedDays, 1, 366)
: 7;
var exerciseType = args.TryGetProperty("exercise_type", out var typeValue) ? typeValue.GetString() : "散步";
var minutes = args.TryGetProperty("duration_minutes", out var minutesValue) && minutesValue.TryGetInt32(out var parsedMinutes) ? parsedMinutes : 30;
var reminder = args.TryGetProperty("reminder_time", out var reminderValue) && TimeOnly.TryParse(reminderValue.GetString(), out var parsedReminder)
? parsedReminder
: new TimeOnly(19, 0);
var planId = await exercises.CreateAsync(userId, new ExercisePlanCreateRequest(
startDate, startDate.AddDays(days - 1), exerciseType, minutes, reminder), ct);
return new { success = true, plan_id = planId, exercise_type = exerciseType, duration_minutes = minutes, day_count = days, start_date = startDate, end_date = startDate.AddDays(days - 1), reminder_time = reminder };
}
if (action == "checkin")
{
var itemId = args.TryGetProperty("item_id", out var itemValue) && itemValue.TryGetGuid(out var parsedId) ? parsedId : Guid.Empty;
var success = await exercises.CheckInAsync(userId, itemId, ct);
return success ? new { success = true } : new { success = false, message = "条目不存在" };
}
var plan = await exercises.GetLatestAsync(userId, ct);
return plan == null
? new { found = false }
: new
{
found = true,
plan_id = plan.Id,
plan.StartDate,
plan.EndDate,
plan.ReminderTime,
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
};
}
}

View File

@@ -1,3 +1,5 @@
using Health.Application.HealthRecords;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
@@ -16,16 +18,50 @@ public static class HealthDataAgentHandler
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId, IHealthRecordService? healthRecords = null)
{
return toolName switch
{
"record_health_data" => await ExecuteRecordHealthData(db, userId, args),
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId),
"record_health_data" => healthRecords == null
? await ExecuteRecordHealthData(db, userId, args)
: await ExecuteRecordHealthData(healthRecords, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null)
return new { success = false, message = $"未知指标类型: {type}" };
var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt)
? dt
: DateTime.UtcNow;
var request = new HealthRecordUpsertRequest(
metricType.Value,
systolic,
diastolic,
value,
unit,
HealthRecordSource.AiEntry,
recordedAt);
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None);
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
return new
{
success = true,
record_id = id,
type = metricType.Value.ToString(),
value = valStr,
unit,
isAbnormal = HealthRecordRules.CheckAbnormal(request)
};
}
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
@@ -82,4 +118,42 @@ public static class HealthDataAgentHandler
};
return new { success = true, record_id = record.Id, type = record.MetricType.ToString(), value = valStr, unit = record.Unit, isAbnormal = record.IsAbnormal };
}
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
{
return type switch
{
"blood_pressure" => (
HealthMetricType.BloodPressure,
null,
"mmHg",
args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null,
args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null),
"heart_rate" => (
HealthMetricType.HeartRate,
args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null,
"次/分",
null,
null),
"glucose" => (
HealthMetricType.Glucose,
args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null,
"mmol/L",
null,
null),
"spo2" => (
HealthMetricType.SpO2,
args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null,
"%",
null,
null),
"weight" => (
HealthMetricType.Weight,
args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null,
"kg",
null,
null),
_ => (null, null, "", null, null)
};
}
}

View File

@@ -1,7 +1,9 @@
using Health.Application.Medications;
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 药管家 Agent 工具处理器——用药管理
/// 药管家 Agent 工具处理器
/// </summary>
public static class MedicationAgentHandler
{
@@ -9,31 +11,105 @@ public static class MedicationAgentHandler
{
Function = new()
{
Name = "manage_medication", Description = "用药管理(创建/查询/确认服药)",
Parameters = new { type = "object", properties = new {
action = new { type = "string", description = "create/query/confirm" },
name = new { type = "string", description = "药品名称" },
dosage = new { type = "string", description = "剂量,如 100mg" },
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
duration_days = new { type = "integer", description = "服用天数" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
}, required = new[] { "action" } }
Name = "manage_medication",
Description = "用药管理(创建/查询/确认服药)",
Parameters = new
{
type = "object",
properties = new
{
action = new { type = "string", description = "create/query/confirm" },
medication_id = new { type = "string", description = "药品 ID确认服药时使用" },
name = new { type = "string", description = "药品名称" },
dosage = new { type = "string", description = "剂量,如 100mg" },
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
duration_days = new { type = "integer", description = "服用天数" },
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
},
required = new[] { "action" }
}
}
};
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
public static async Task<object> Execute(
string toolName,
JsonElement args,
AppDbContext db,
Guid userId,
IMedicationService? medications = null,
CancellationToken ct = default)
{
return toolName switch
{
"manage_medication" => await ExecuteManageMedication(db, userId, args),
"check_archive" => await CommonAgentHandler.Execute(toolName, args, db, userId),
"manage_medication" => medications != null
? await ExecuteManageMedication(medications, userId, args, ct)
: await ExecuteManageMedication(db, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
return action switch
{
"create" => await CreateMedication(medications, userId, args, ct),
"query" => await QueryMedications(medications, userId, ct),
"confirm" => await ConfirmMedication(medications, userId, args, ct),
_ => new { success = false, message = $"未知操作: {action}" }
};
}
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequency = ReadFrequency(args);
var times = ReadTimes(args);
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
var startDate = ReadStartDate(args);
var endDate = durationDays > 0 ? startDate.AddDays(durationDays) : (DateOnly?)null;
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
name,
dosage,
frequency,
times.Count > 0 ? times : [new TimeOnly(8, 0)],
startDate,
endDate,
MedicationSource.AiEntry,
null), ct);
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
return new
{
success = true,
medication_id = medicationId,
name,
dosage,
frequency = frequency.ToString(),
time = timeLabels,
start_date = startDate.ToString("yyyy-MM-dd"),
duration_days = durationDays,
};
}
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
{
var meds = await medications.ListActiveAsync(userId, ct);
return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) };
}
private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
{
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
var success = await medications.ConfirmNowAsync(userId, medId, ct);
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
}
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
@@ -50,38 +126,36 @@ public static class MedicationAgentHandler
{
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
var frequency = Enum.TryParse<MedicationFrequency>(frequencyStr, out var fr) ? fr : MedicationFrequency.Daily;
List<TimeOnly> times = [];
if (args.TryGetProperty("time_of_day", out var tod) && tod.ValueKind == JsonValueKind.Array)
{
foreach (var t in tod.EnumerateArray())
{
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
}
}
var frequency = ReadFrequency(args);
var times = ReadTimes(args);
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null;
var startDate = ReadStartDate(args);
var med = new Medication
{
Id = Guid.NewGuid(), UserId = userId,
Name = name, Dosage = dosage, Frequency = frequency,
Id = Guid.NewGuid(),
UserId = userId,
Name = name,
Dosage = dosage,
Frequency = frequency,
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
Source = MedicationSource.AiEntry, IsActive = true,
StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).AddDays(durationDays) : null,
Source = MedicationSource.AiEntry,
IsActive = true,
StartDate = startDate,
EndDate = durationDays > 0 ? startDate.AddDays(durationDays) : null,
};
db.Medications.Add(med);
await db.SaveChangesAsync();
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
return new {
success = true, medication_id = med.Id,
name = med.Name, dosage = med.Dosage,
frequency = med.Frequency.ToString(), time = timeLabels,
return new
{
success = true,
medication_id = med.Id,
name = med.Name,
dosage = med.Dosage,
frequency = med.Frequency.ToString(),
time = timeLabels,
start_date = med.StartDate?.ToString("yyyy-MM-dd"),
duration_days = durationDays,
};
@@ -97,12 +171,47 @@ public static class MedicationAgentHandler
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
{
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == medId && m.UserId == userId);
if (med == null) return new { success = false, message = "药品不存在" };
db.MedicationLogs.Add(new MedicationLog
{
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
Id = Guid.NewGuid(),
MedicationId = medId,
UserId = userId,
Status = MedicationLogStatus.Taken,
ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
ConfirmedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return new { success = true };
}
private static MedicationFrequency ReadFrequency(JsonElement args)
{
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily;
}
private static DateOnly ReadStartDate(JsonElement args)
{
var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null;
return DateOnly.TryParse(startDateStr, out var parsedDate)
? parsedDate
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
}
private static List<TimeOnly> ReadTimes(JsonElement args)
{
List<TimeOnly> times = [];
if (!args.TryGetProperty("time_of_day", out var tod) || tod.ValueKind != JsonValueKind.Array)
return times;
foreach (var t in tod.EnumerateArray())
{
if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed);
}
return times;
}
}

View File

@@ -12,15 +12,6 @@ public static class ReportAgentHandler
public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
public static async Task<object> AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient)
{
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
@@ -28,13 +19,13 @@ public static class ReportAgentHandler
return new { success = false, message = "缺少报告图片" };
var prompt = """
JSON格式返回
JSON格式返回
{
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
"indicators": [
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
],
"summary": "初步分析摘要",
"summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生",
"needsDoctorReview": true/false
}
JSON

View File

@@ -0,0 +1,78 @@
using System.Text.Json;
using Health.Application.AI;
using Health.Application.Exercises;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Medications;
using Health.Infrastructure.AI.AgentHandlers;
namespace Health.Infrastructure.AI;
public sealed class AiToolExecutionService(
AppDbContext db,
VisionClient visionClient,
IHealthArchiveService healthArchives,
IHealthRecordService healthRecords,
IExerciseService exercises,
IMedicationService medications,
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
{
private readonly AppDbContext _db = db;
private readonly VisionClient _visionClient = visionClient;
private readonly IHealthArchiveService _healthArchives = healthArchives;
private readonly IHealthRecordService _healthRecords = healthRecords;
private readonly IExerciseService _exercises = exercises;
private readonly IMedicationService _medications = medications;
private readonly IAiWriteConfirmationStore _confirmations = confirmations;
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
{
using var json = JsonDocument.Parse(arguments);
var root = json.RootElement;
return await (toolName switch
{
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, _db, userId, _healthRecords),
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, _db, userId, _medications, ct),
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }),
});
}
public async Task<AiConfirmedWriteResult> ConfirmAsync(Guid commandId, Guid userId, CancellationToken ct)
{
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
var command = await _confirmations.TakeAsync(commandId, userId, ct);
if (command == null)
return new AiConfirmedWriteResult(40004, null, "确认请求不存在、已执行或已过期");
try
{
var result = await ExecuteAsync(command.ToolName, command.Arguments, userId, ct);
if (!Succeeded(result, out var error))
{
await transaction.RollbackAsync(ct);
return new AiConfirmedWriteResult(40001, result, error ?? "写入失败,请检查内容后重试");
}
await _confirmations.CompleteAsync(command, ct);
await transaction.CommitAsync(ct);
return new AiConfirmedWriteResult(0, result, null);
}
catch (Exception ex)
{
await transaction.RollbackAsync(ct);
return new AiConfirmedWriteResult(50001, null, $"写入失败: {ex.Message}");
}
}
private static bool Succeeded(object result, out string? error)
{
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
var root = json.RootElement;
error = root.TryGetProperty("message", out var message) ? message.GetString() : null;
return !root.TryGetProperty("success", out var success) || success.ValueKind != JsonValueKind.False;
}
}

View File

@@ -0,0 +1,45 @@
using Health.Application.AI;
namespace Health.Infrastructure.AI;
public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversationRepository
{
private readonly AppDbContext _db = db;
public Task<Conversation?> GetOwnedAsync(Guid userId, Guid conversationId, CancellationToken ct) =>
_db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId, ct);
public Task<Conversation?> GetAsync(Guid conversationId, CancellationToken ct) =>
_db.Conversations.FirstOrDefaultAsync(c => c.Id == conversationId, ct);
public async Task<IReadOnlyList<Conversation>> ListAsync(Guid userId, CancellationToken ct) =>
await _db.Conversations
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.UpdatedAt)
.ToListAsync(ct);
public async Task<IReadOnlyList<ConversationMessage>> GetMessagesAsync(Guid conversationId, CancellationToken ct) =>
await _db.ConversationMessages
.Where(m => m.ConversationId == conversationId)
.OrderBy(m => m.CreatedAt)
.ToListAsync(ct);
public async Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) =>
await _db.ConversationMessages
.Where(m => m.ConversationId == conversationId)
.OrderByDescending(m => m.CreatedAt)
.Take(limit)
.ToListAsync(ct);
public async Task AddConversationAsync(Conversation conversation, CancellationToken ct) =>
await _db.Conversations.AddAsync(conversation, ct);
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
await _db.ConversationMessages.AddAsync(message, ct);
public void Delete(Conversation conversation) =>
_db.Conversations.Remove(conversation);
public Task SaveChangesAsync(CancellationToken ct) =>
_db.SaveChangesAsync(ct);
}

View File

@@ -0,0 +1,98 @@
using Health.Application.AI;
using Health.Infrastructure.Data.Records;
namespace Health.Infrastructure.AI;
public sealed class EfAiWriteConfirmationStore(AppDbContext db) : IAiWriteConfirmationStore
{
private readonly AppDbContext _db = db;
public async Task<PendingAiWriteCommand> CreateAsync(
Guid userId,
string toolName,
string arguments,
TimeSpan lifetime,
CancellationToken ct)
{
var now = DateTime.UtcNow;
if (IsInMemory())
{
var expired = await _db.AiWriteCommands.Where(x => x.Status == "Pending" && x.ExpiresAt <= now).ToListAsync(ct);
foreach (var item in expired) { item.Status = "Expired"; item.UpdatedAt = now; }
}
else
{
await _db.AiWriteCommands
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Expired")
.SetProperty(x => x.UpdatedAt, now), ct);
}
var command = new AiWriteCommandRecord
{
Id = Guid.NewGuid(),
UserId = userId,
ToolName = toolName,
Arguments = arguments,
Status = "Pending",
ExpiresAt = now.Add(lifetime),
CreatedAt = now,
UpdatedAt = now,
};
await _db.AiWriteCommands.AddAsync(command, ct);
await _db.SaveChangesAsync(ct);
return ToCommand(command);
}
public async Task<PendingAiWriteCommand?> TakeAsync(Guid commandId, Guid userId, CancellationToken ct)
{
var now = DateTime.UtcNow;
if (IsInMemory())
{
var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync(
x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now, ct);
if (tracked == null) return null;
tracked.Status = "Processing";
tracked.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
}
else
{
var updated = await _db.AiWriteCommands
.Where(x => x.Id == commandId && x.UserId == userId && x.Status == "Pending" && x.ExpiresAt > now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Processing")
.SetProperty(x => x.UpdatedAt, now), ct);
if (updated == 0) return null;
}
var record = await _db.AiWriteCommands.AsNoTracking().FirstAsync(x => x.Id == commandId, ct);
return ToCommand(record);
}
public async Task CompleteAsync(PendingAiWriteCommand command, CancellationToken ct)
{
if (IsInMemory())
{
var tracked = await _db.AiWriteCommands.FirstOrDefaultAsync(
x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing", ct);
if (tracked != null) { tracked.Status = "Completed"; tracked.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); }
return;
}
await _db.AiWriteCommands
.Where(x => x.Id == command.Id && x.UserId == command.UserId && x.Status == "Processing")
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Completed")
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
}
private bool IsInMemory() => _db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory";
private static PendingAiWriteCommand ToCommand(AiWriteCommandRecord record) => new(
record.Id,
record.UserId,
record.ToolName,
record.Arguments,
record.ExpiresAt);
}

View File

@@ -8,18 +8,33 @@ public sealed class PromptManager
/// <summary>
/// 获取指定 Agent 的 System Prompt
/// </summary>
public string GetSystemPrompt(AgentType agentType) => agentType switch
public string GetSystemPrompt(AgentType agentType)
{
AgentType.Default => DefaultPrompt,
AgentType.Consultation => ConsultationPrompt,
AgentType.Health => HealthDataPrompt,
AgentType.Diet => DietPrompt,
AgentType.Medication => MedicationPrompt,
AgentType.Report => ReportPrompt,
AgentType.Exercise => ExercisePrompt,
AgentType.Unified => UnifiedPrompt,
_ => DefaultPrompt
};
var prompt = agentType switch
{
AgentType.Default => DefaultPrompt,
AgentType.Consultation => ConsultationPrompt,
AgentType.Health => HealthDataPrompt,
AgentType.Diet => DietPrompt,
AgentType.Medication => MedicationPrompt,
AgentType.Report => ReportPrompt,
AgentType.Exercise => ExercisePrompt,
AgentType.Unified => UnifiedPrompt,
_ => DefaultPrompt
};
return $"{prompt}\n\n{MedicalBoundaryRules}";
}
private const string MedicalBoundaryRules = """
- AI
- //
-
-
- 使
- AI
""";
private const string DefaultPrompt = """
AI健康管家
@@ -38,15 +53,15 @@ public sealed class PromptManager
""";
private const string ConsultationPrompt = """
AI
1.
2. 2-3
3.
4.
5. >160/100>120<50
6. "以上为AI分析具体请咨询医生"
5. >160/100>120<50
6.
7.
""";
@@ -55,13 +70,13 @@ public sealed class PromptManager
1. ////
2. + record_health_data
2. + record_health_data
3. "血压120/80血糖6.2血氧97" record_health_data
4. "早上血压120下午血压130"recorded_at
4. "早上血压120下午血压130"recorded_at
5. "120""收缩压还是血糖?"
6.
7.
8. record_health_data
8. record_health_data
- 90-139 mmHg 60-89 mmHg
@@ -86,24 +101,24 @@ public sealed class PromptManager
""";
private const string MedicationPrompt = """
1. manage_medication(action="query")
2. ///
3. "早饭后"
4.
4. manage_medication(action="create")
5.
6.
6.
""";
private const string ReportPrompt = """
1.
2. //
3.
3.
4. "AI预解读待医生确认"
5. /CT"需医生人工审阅"
""";
@@ -120,7 +135,7 @@ public sealed class PromptManager
""";
private const string UnifiedPrompt = """
AI健康管
AI
@@ -133,20 +148,21 @@ public sealed class PromptManager
6. query_health_records
manage_exercise
- action="create", week_start_date="今天日期(YYYY-MM-DD)"
- items: [{"day_of_week":0-6(=0),"exercise_type":"散步","duration_minutes":30,"is_rest_day":false}, ...]
- action="create", start_date="开始日期(YYYY-MM-DD,默认今天)"
- duration_days=exercise_type="散步"duration_minutes=30reminder_time="19:00"
- =30 =60 =30 =90 =80
- "一个月"=30 "一周"=7 "10天"=107
- "坚持X天/月/周"
- items
- start_date + duration_days - 1
manage_medication
- action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7
-
- +
-
- +
-
- pendingConfirmation=true
-
-
""";