using Health.Application.Diets; using Health.Application.Notifications; using Health.Application.Reports; namespace Health.Infrastructure.AI.AgentHandlers; /// /// 统一助手使用的患者本人业务数据只读工具。 /// 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 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 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 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 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] + "…"; }