feat: AI Agent 处理器扩展 + 服药打卡确认链路 + AI 气泡配色微调 + 数据刷新 providers 抽离 + 多端页面细节调整

This commit is contained in:
MingNian
2026-07-20 17:12:00 +08:00
parent 9cea41705e
commit 0cb5b8e85a
45 changed files with 1435 additions and 276 deletions

View File

@@ -0,0 +1,257 @@
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 = "查询复查安排时必须明确 scopenext、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] + "…";
}