258 lines
10 KiB
C#
258 lines
10 KiB
C#
using Health.Application.Diets;
|
||
using Health.Application.Notifications;
|
||
using Health.Application.Reports;
|
||
|
||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||
|
||
/// <summary>
|
||
/// 统一助手使用的患者本人业务数据只读工具。
|
||
/// </summary>
|
||
public static class PatientReadAgentHandler
|
||
{
|
||
public static readonly ToolDefinition QueryDietRecordsTool = new()
|
||
{
|
||
Function = new()
|
||
{
|
||
Name = "query_diet_records",
|
||
Description = "查询当前用户指定北京时间日期的饮食记录和总热量",
|
||
Parameters = new
|
||
{
|
||
type = "object",
|
||
properties = new
|
||
{
|
||
date = new { type = "string", description = "北京时间日期 yyyy-MM-dd,默认今天" },
|
||
meal_type = new { type = "string", @enum = new[] { "Breakfast", "Lunch", "Dinner", "Snack" }, description = "可选餐次" },
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
public static readonly ToolDefinition QueryFollowUpsTool = new()
|
||
{
|
||
Function = new()
|
||
{
|
||
Name = "query_followups",
|
||
Description = "查询当前用户的复查随访安排",
|
||
Parameters = new
|
||
{
|
||
type = "object",
|
||
properties = new
|
||
{
|
||
scope = new { type = "string", @enum = new[] { "next", "upcoming", "completed", "date", "all" }, description = "查询时必填:下一次/未来安排/已完成/指定日期/全部" },
|
||
date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" },
|
||
limit = new { type = "integer", description = "最多返回条数,1到20" },
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
public static readonly ToolDefinition QueryReportsTool = new()
|
||
{
|
||
Function = new()
|
||
{
|
||
Name = "query_reports",
|
||
Description = "查询当前用户已经保存的检查报告状态、AI摘要和医生审核结论,不读取其他用户报告",
|
||
Parameters = new
|
||
{
|
||
type = "object",
|
||
properties = new
|
||
{
|
||
limit = new { type = "integer", description = "最多返回条数,1到10,默认5" },
|
||
status = new { type = "string", description = "可选报告状态" },
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
public static readonly ToolDefinition QueryNotificationsTool = new()
|
||
{
|
||
Function = new()
|
||
{
|
||
Name = "query_notifications",
|
||
Description = "查询当前用户的站内通知",
|
||
Parameters = new
|
||
{
|
||
type = "object",
|
||
properties = new
|
||
{
|
||
scope = new { type = "string", @enum = new[] { "unread", "today", "recent" }, description = "默认 unread" },
|
||
limit = new { type = "integer", description = "最多返回条数,1到20" },
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
public static async Task<object> QueryDietAsync(
|
||
IDietService diets,
|
||
Guid userId,
|
||
JsonElement args,
|
||
CancellationToken ct)
|
||
{
|
||
if (args.TryGetProperty("date", out var suppliedDate) && !DateOnly.TryParse(suppliedDate.GetString(), out _))
|
||
return new { success = false, message = "查询饮食记录时提供了无效的 date" };
|
||
var date = args.TryGetProperty("date", out var dateValue) && DateOnly.TryParse(dateValue.GetString(), out var parsedDate)
|
||
? parsedDate
|
||
: AiDateTime.BeijingToday;
|
||
var mealType = args.TryGetProperty("meal_type", out var mealValue) ? mealValue.GetString() : null;
|
||
var records = await diets.ListAsync(userId, date.ToString("yyyy-MM-dd"), mealType, ct);
|
||
return new
|
||
{
|
||
query_date = date.ToString("yyyy-MM-dd"),
|
||
count = records.Count,
|
||
total_calories = records.Sum(record => record.TotalCalories ?? 0),
|
||
records = records.Select(record => new
|
||
{
|
||
record.Id,
|
||
record.MealType,
|
||
record.TotalCalories,
|
||
record.HealthScore,
|
||
record.RecordedAt,
|
||
record.FoodItems,
|
||
}),
|
||
};
|
||
}
|
||
|
||
public static async Task<object> QueryFollowUpsAsync(
|
||
AppDbContext db,
|
||
Guid userId,
|
||
JsonElement args,
|
||
CancellationToken ct)
|
||
{
|
||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||
? scopeValue.GetString()?.ToLowerInvariant()
|
||
: null;
|
||
if (scope is not ("next" or "upcoming" or "completed" or "date" or "all"))
|
||
return new
|
||
{
|
||
success = false,
|
||
message = "查询复查安排时必须明确 scope:next、upcoming、completed、date 或 all",
|
||
};
|
||
var limit = ReadLimit(args, 10, 20);
|
||
var nowUtc = DateTime.UtcNow;
|
||
var query = db.FollowUps.AsNoTracking().Where(item => item.UserId == userId);
|
||
|
||
if (scope is "next" or "upcoming")
|
||
query = query.Where(item => item.Status == FollowUpStatus.Upcoming && item.ScheduledAt >= nowUtc);
|
||
else if (scope == "completed")
|
||
query = query.Where(item => item.Status == FollowUpStatus.Completed);
|
||
else if (scope == "date")
|
||
{
|
||
if (!args.TryGetProperty("date", out var dateValue) || !DateOnly.TryParse(dateValue.GetString(), out var date))
|
||
return new { success = false, message = "按日期查询复查时必须提供有效的 date" };
|
||
var (startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date);
|
||
query = query.Where(item => item.ScheduledAt >= startUtc && item.ScheduledAt < endUtc);
|
||
}
|
||
|
||
var take = scope == "next" ? 1 : limit;
|
||
var orderedQuery = scope is "all" or "completed"
|
||
? query.OrderByDescending(item => item.ScheduledAt)
|
||
: query.OrderBy(item => item.ScheduledAt);
|
||
var matchingCount = await query.CountAsync(ct);
|
||
var items = await orderedQuery
|
||
.Take(take)
|
||
.Select(item => new
|
||
{
|
||
item.Id,
|
||
item.Title,
|
||
item.DoctorName,
|
||
item.Department,
|
||
item.ScheduledAt,
|
||
item.Notes,
|
||
item.Status,
|
||
})
|
||
.ToListAsync(ct);
|
||
return new
|
||
{
|
||
success = true,
|
||
scope,
|
||
count = items.Count,
|
||
matching_count = matchingCount,
|
||
results_truncated = matchingCount > items.Count,
|
||
followups = items.Select(item => new
|
||
{
|
||
item.Id,
|
||
item.Title,
|
||
item.DoctorName,
|
||
item.Department,
|
||
scheduled_at_beijing = AiDateTime.ToBeijingText(item.ScheduledAt),
|
||
item.Notes,
|
||
status = item.Status.ToString(),
|
||
}),
|
||
};
|
||
}
|
||
|
||
public static async Task<object> QueryReportsAsync(
|
||
IReportService reports,
|
||
Guid userId,
|
||
JsonElement args,
|
||
CancellationToken ct)
|
||
{
|
||
var limit = ReadLimit(args, 5, 10);
|
||
var status = args.TryGetProperty("status", out var statusValue) ? statusValue.GetString() : null;
|
||
var records = await reports.GetReportsAsync(userId, ct);
|
||
var filtered = records
|
||
.Where(report => string.IsNullOrWhiteSpace(status) || report.Status.Equals(status, StringComparison.OrdinalIgnoreCase))
|
||
.OrderByDescending(report => report.CreatedAt)
|
||
.Take(limit)
|
||
.Select(report => new
|
||
{
|
||
report.Id,
|
||
report.Category,
|
||
report.Status,
|
||
report.AiStatus,
|
||
report.ReviewStatus,
|
||
report.Severity,
|
||
ai_summary = Truncate(report.AiSummary, 1800),
|
||
doctor_comment = Truncate(report.DoctorComment, 1000),
|
||
doctor_recommendation = Truncate(report.DoctorRecommendation, 1000),
|
||
report.DoctorName,
|
||
reviewed_at_beijing = report.ReviewedAt.HasValue ? AiDateTime.ToBeijingText(report.ReviewedAt.Value) : null,
|
||
created_at_beijing = AiDateTime.ToBeijingText(report.CreatedAt),
|
||
})
|
||
.ToList();
|
||
return new { count = filtered.Count, reports = filtered };
|
||
}
|
||
|
||
public static async Task<object> QueryNotificationsAsync(
|
||
IInAppNotificationService notifications,
|
||
Guid userId,
|
||
JsonElement args,
|
||
CancellationToken ct)
|
||
{
|
||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||
? scopeValue.GetString()?.ToLowerInvariant() ?? "unread"
|
||
: "unread";
|
||
if (scope is not ("unread" or "today" or "recent")) scope = "unread";
|
||
var limit = ReadLimit(args, 10, 20);
|
||
var records = scope == "unread"
|
||
? await notifications.GetPendingAsync(userId, ct)
|
||
: await notifications.GetHistoryAsync(userId, ct);
|
||
var (todayStartUtc, todayEndUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday);
|
||
var filtered = records
|
||
.Where(item => scope != "today" ||
|
||
(AiDateTime.ToUtc(item.CreatedAt) >= todayStartUtc && AiDateTime.ToUtc(item.CreatedAt) < todayEndUtc))
|
||
.OrderByDescending(item => item.CreatedAt)
|
||
.Take(limit)
|
||
.Select(item => new
|
||
{
|
||
item.Id,
|
||
item.Type,
|
||
item.Title,
|
||
item.Message,
|
||
item.Severity,
|
||
item.IsRead,
|
||
created_at_beijing = AiDateTime.ToBeijingText(item.CreatedAt),
|
||
})
|
||
.ToList();
|
||
return new { scope, count = filtered.Count, notifications = filtered };
|
||
}
|
||
|
||
private static int ReadLimit(JsonElement args, int defaultValue, int max) =>
|
||
args.TryGetProperty("limit", out var value) && value.TryGetInt32(out var parsed)
|
||
? Math.Clamp(parsed, 1, max)
|
||
: defaultValue;
|
||
|
||
private static string? Truncate(string? value, int maxLength) =>
|
||
string.IsNullOrWhiteSpace(value) || value.Length <= maxLength ? value : value[..maxLength] + "…";
|
||
}
|