diff --git a/backend/src/Health.Application/AI/PatientContextService.cs b/backend/src/Health.Application/AI/PatientContextService.cs index 1234725..3fea208 100644 --- a/backend/src/Health.Application/AI/PatientContextService.cs +++ b/backend/src/Health.Application/AI/PatientContextService.cs @@ -1,24 +1,20 @@ using System.Text; using Health.Application.HealthArchives; using Health.Application.HealthRecords; -using Health.Application.Medications; namespace Health.Application.AI; public sealed class PatientContextService( IHealthArchiveService archives, - IHealthRecordService healthRecords, - IMedicationService medications) : IPatientContextService + IHealthRecordService healthRecords) : IPatientContextService { private readonly IHealthArchiveService _archives = archives; private readonly IHealthRecordService _healthRecords = healthRecords; - private readonly IMedicationService _medications = medications; public async Task BuildAsync(Guid userId, CancellationToken ct) { var archive = await _archives.GetAsync(userId, ct); var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList(); - var activeMedications = await _medications.ListActiveAsync(userId, ct); var sb = new StringBuilder(); if (archive != null) @@ -36,23 +32,11 @@ public sealed class PatientContextService( if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}"); } - if (activeMedications.Count > 0) - { - sb.AppendLine("当前用药:"); - foreach (var medication in activeMedications.Take(20)) - { - var times = medication.TimeOfDay.Count > 0 - ? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm"))) - : "未设置时间"; - sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}"); - } - } - if (recentRecords.Count > 0) { sb.AppendLine("近期健康数据:"); foreach (var record in recentRecords) - sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})"); + sb.AppendLine($" {record.Type}: {RecordValue(record)} ({ToBeijing(record.RecordedAt):MM-dd HH:mm} 北京时间)"); } return sb.ToString(); @@ -67,4 +51,15 @@ public sealed class PatientContextService( "Weight" => $"{record.Value}kg", _ => "—" }; + + private static DateTime ToBeijing(DateTime value) + { + var utc = value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc), + }; + return utc.AddHours(8); + } } diff --git a/backend/src/Health.Application/Exercises/ExerciseContracts.cs b/backend/src/Health.Application/Exercises/ExerciseContracts.cs index 399711f..76817a5 100644 --- a/backend/src/Health.Application/Exercises/ExerciseContracts.cs +++ b/backend/src/Health.Application/Exercises/ExerciseContracts.cs @@ -42,19 +42,19 @@ public interface IExerciseService Task GetCurrentAsync(Guid userId, CancellationToken ct); Task CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct); Task> ListAsync(Guid userId, CancellationToken ct); + Task> ListAllAsync(Guid userId, CancellationToken ct); Task GetByIdAsync(Guid userId, Guid planId, CancellationToken ct); Task DeleteAsync(Guid userId, Guid planId, CancellationToken ct); Task ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct); Task CheckInAsync(Guid userId, Guid itemId, CancellationToken ct); - Task GetLatestAsync(Guid userId, CancellationToken ct); } public interface IExerciseRepository { Task> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct); Task> ListAsync(Guid userId, int limit, CancellationToken ct); + Task> ListAllAsync(Guid userId, CancellationToken ct); Task GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct); - Task GetLatestAsync(Guid userId, CancellationToken ct); Task GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct); Task AddAsync(ExercisePlan plan, CancellationToken ct); void Delete(ExercisePlan plan); diff --git a/backend/src/Health.Application/Exercises/ExerciseService.cs b/backend/src/Health.Application/Exercises/ExerciseService.cs index 27810b9..9390ca5 100644 --- a/backend/src/Health.Application/Exercises/ExerciseService.cs +++ b/backend/src/Health.Application/Exercises/ExerciseService.cs @@ -33,8 +33,11 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe { plan.Items.Add(new ExercisePlanItem { - Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType, - DurationMinutes = duration, IsRestDay = false, + Id = Guid.NewGuid(), + ScheduledDate = date, + ExerciseType = exerciseType, + DurationMinutes = duration, + IsRestDay = false, }); } await SaveNewAsync(plan, ct); @@ -52,10 +55,13 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe public async Task> ListAsync(Guid userId, CancellationToken ct) { var plans = await _exercises.ListAsync(userId, 20, ct); - return plans.Select(p => new ExercisePlanSummaryDto( - p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt, - p.Items.Count, p.Items.Count(i => i.IsCompleted), - p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList(); + return plans.Select(ToSummaryDto).ToList(); + } + + public async Task> ListAllAsync(Guid userId, CancellationToken ct) + { + var plans = await _exercises.ListAllAsync(userId, ct); + return plans.Select(ToSummaryDto).ToList(); } public async Task GetByIdAsync(Guid userId, Guid planId, CancellationToken ct) @@ -103,12 +109,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe return true; } - public async Task GetLatestAsync(Guid userId, CancellationToken ct) - { - var plan = await _exercises.GetLatestAsync(userId, ct); - return plan == null ? null : ToDto(plan); - } - private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct) { await _exercises.AddAsync(plan, ct); @@ -117,15 +117,24 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new() { - Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate, + Id = Guid.NewGuid(), + UserId = userId, + StartDate = startDate, + EndDate = endDate, ReminderTime = reminderTime ?? DefaultReminderTime, - CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, }; private static ExercisePlanDto ToDto(ExercisePlan plan) => new( plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt, plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList()); + private static ExercisePlanSummaryDto ToSummaryDto(ExercisePlan plan) => new( + plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, + plan.Items.Count, plan.Items.Count(i => i.IsCompleted), + plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList()); + private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new( item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes, item.IsCompleted, item.CompletedAt, item.IsRestDay); diff --git a/backend/src/Health.Application/Medications/MedicationContracts.cs b/backend/src/Health.Application/Medications/MedicationContracts.cs index ab4751b..785baa4 100644 --- a/backend/src/Health.Application/Medications/MedicationContracts.cs +++ b/backend/src/Health.Application/Medications/MedicationContracts.cs @@ -45,6 +45,21 @@ public sealed record MedicationReminderDto( string ScheduledTime, string Status); +public sealed record AiMedicationDoseDto( + string ScheduledTime, + string Status); + +public sealed record AiMedicationPlanDto( + Guid Id, + string Name, + string? Dosage, + string Frequency, + DateOnly? StartDate, + DateOnly? EndDate, + string Phase, + bool ScheduledOnDate, + IReadOnlyList Doses); + public sealed record MedicationReminderTask( Guid TaskId, Guid UserId, @@ -60,12 +75,10 @@ public interface IMedicationService Task CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct); Task UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct); Task DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct); - Task ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct); Task> GetRemindersAsync(Guid userId, CancellationToken ct); Task ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct); Task CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct); - Task> ListActiveAsync(Guid userId, CancellationToken ct); - Task ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct); + Task> GetAiOverviewAsync(Guid userId, DateOnly date, CancellationToken ct); } public interface IMedicationRepository @@ -74,8 +87,6 @@ public interface IMedicationRepository Task> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct); Task> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct); Task GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct); - Task ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct); - Task GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct); Task DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct); Task GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct); Task> ListActiveForReminderScanAsync(CancellationToken ct); diff --git a/backend/src/Health.Application/Medications/MedicationService.cs b/backend/src/Health.Application/Medications/MedicationService.cs index 1380fd5..d916966 100644 --- a/backend/src/Health.Application/Medications/MedicationService.cs +++ b/backend/src/Health.Application/Medications/MedicationService.cs @@ -96,23 +96,6 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi return true; } - public async Task ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct) - { - if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null; - - var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow(); - var existing = await _medications.GetTodayTakenLogAsync(userId, medicationId, todayStartUtc, todayEndUtc, ct); - if (existing != null) - { - _medications.DeleteLog(existing); - await _medications.SaveChangesAsync(ct); - return false; - } - - await AddLogAsync(userId, medicationId, MedicationLogStatus.Taken, TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct); - return true; - } - public async Task> GetRemindersAsync(Guid userId, CancellationToken ct) { var beijingNow = DateTime.UtcNow.AddHours(8); @@ -147,7 +130,14 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi public async Task ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct) { - if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null; + var medication = await _medications.GetOwnedAsync(userId, medicationId, ct); + if (medication == null) return null; + + var today = BeijingToday(); + if (!IsActiveOn(medication, today) || + !GetDosesForToday(medication, today) || + !medication.TimeOfDay.Contains(scheduledTime)) + return false; var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow(); var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct); @@ -168,18 +158,48 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi return true; } - public Task> ListActiveAsync(Guid userId, CancellationToken ct) => - ListAsync(userId, "active", ct); - - public async Task ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct) + public async Task> GetAiOverviewAsync( + Guid userId, + DateOnly date, + CancellationToken ct) { - var result = await ConfirmDoseAsync( - userId, - medicationId, - TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), - MedicationLogStatus.Taken, - ct); - return result == true; + var medications = await _medications.ListAsync(userId, null, ct); + var (startUtc, endUtc) = GetBeijingDateUtcWindow(date); + var logs = await _medications.ListLogsInWindowAsync(userId, startUtc, endUtc, ct); + var today = BeijingToday(); + var now = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + + return medications.Select(medication => + { + var phase = ResolvePhase(medication, date); + var scheduled = phase == "active" && GetDosesForToday(medication, date); + var doses = scheduled + ? medication.TimeOfDay.OrderBy(time => time).Select(time => + { + var log = logs.FirstOrDefault(item => + item.MedicationId == medication.Id && item.ScheduledTime == time); + var status = log != null + ? log.Status.ToString().ToLowerInvariant() + : date < today + ? "missed" + : date > today + ? "scheduled" + : MedicationScheduleStatus.Resolve(time, now, null); + return new AiMedicationDoseDto(time.ToString("HH:mm"), status); + }).ToList() + : []; + + return new AiMedicationPlanDto( + medication.Id, + medication.Name, + medication.Dosage, + medication.Frequency.ToString(), + medication.StartDate, + medication.EndDate, + phase, + scheduled, + doses); + }).ToList(); } private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct) @@ -217,9 +237,28 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow() { - var beijingToday = DateTime.UtcNow.AddHours(8).Date; - var todayStartUtc = beijingToday.AddHours(-8); - return (todayStartUtc, todayStartUtc.AddDays(1)); + return GetBeijingDateUtcWindow(BeijingToday()); + } + + private static (DateTime StartUtc, DateTime EndUtc) GetBeijingDateUtcWindow(DateOnly date) + { + var startUtc = DateTime.SpecifyKind(date.ToDateTime(TimeOnly.MinValue).AddHours(-8), DateTimeKind.Utc); + return (startUtc, startUtc.AddDays(1)); + } + + private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + + private static bool IsActiveOn(Medication medication, DateOnly date) => + medication.IsActive && + (!medication.StartDate.HasValue || medication.StartDate.Value <= date) && + (!medication.EndDate.HasValue || medication.EndDate.Value >= date); + + private static string ResolvePhase(Medication medication, DateOnly date) + { + if (!medication.IsActive) return "inactive"; + if (medication.StartDate.HasValue && medication.StartDate.Value > date) return "upcoming"; + if (medication.EndDate.HasValue && medication.EndDate.Value < date) return "ended"; + return "active"; } private static bool GetDosesForToday(Medication med, DateOnly today) diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/ai_date_time.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/ai_date_time.cs new file mode 100644 index 0000000..07dbaf9 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/ai_date_time.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Health.Infrastructure.AI.AgentHandlers; + +internal static partial class AiDateTime +{ + public static DateTime BeijingNow => DateTime.UtcNow.AddHours(8); + public static DateOnly BeijingToday => DateOnly.FromDateTime(BeijingNow); + + public static (DateTime StartUtc, DateTime EndUtc) BeijingDateWindowUtc(DateOnly date) + { + var startUtc = DateTime.SpecifyKind(date.ToDateTime(TimeOnly.MinValue).AddHours(-8), DateTimeKind.Utc); + return (startUtc, startUtc.AddDays(1)); + } + + public static DateTime ToUtc(DateTime value) => value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc), + }; + + public static string ToBeijingText(DateTime value) => + ToUtc(value).AddHours(8).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); + + public static bool TryParseUserDateTime(string? value, out DateTime utc) + { + utc = default; + if (string.IsNullOrWhiteSpace(value)) return false; + + if ((value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || OffsetSuffix().IsMatch(value)) && + DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var offsetValue)) + { + utc = offsetValue.UtcDateTime; + return true; + } + + if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var beijingValue)) + return false; + + utc = DateTime.SpecifyKind(beijingValue, DateTimeKind.Unspecified).AddHours(-8); + utc = DateTime.SpecifyKind(utc, DateTimeKind.Utc); + return true; + } + + [GeneratedRegex(@"[+-]\d{2}:?\d{2}$")] + private static partial Regex OffsetSuffix(); +} diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs index 3ed5565..37b4524 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs @@ -12,8 +12,21 @@ public static class CommonAgentHandler { Function = new() { - Name = "query_health_records", Description = "查询近期健康数据", - Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } } + Name = "query_health_records", + Description = "查询近期健康数据", + Parameters = new + { + type = "object", + properties = new + { + type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight", "all" }, description = "健康指标类型" }, + scope = new { type = "string", @enum = new[] { "today", "yesterday", "date", "recent_days", "range" }, description = "日期范围,默认 recent_days" }, + date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" }, + start_date = new { type = "string", description = "scope=range 时的北京时间开始日期 yyyy-MM-dd" }, + end_date = new { type = "string", description = "scope=range 时的北京时间结束日期 yyyy-MM-dd(包含)" }, + days = new { type = "integer", description = "scope=recent_days 时的天数,1到365" }, + } + } } }; @@ -42,10 +55,67 @@ public static class CommonAgentHandler private static async Task 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 records = await healthRecords.GetRecordsAsync(userId, type, days, ct); - return new { count = records.Count, records }; + var rawType = args.TryGetProperty("type", out var t) ? t.GetString() : null; + if (!TryNormalizeMetricType(rawType, out var metricType)) + return new { success = false, message = $"无法识别健康指标类型: {rawType}" }; + + var scope = args.TryGetProperty("scope", out var scopeValue) + ? scopeValue.GetString()?.ToLowerInvariant() + : "recent_days"; + DateTime? startUtc = null; + DateTime? endUtc = null; + int days; + + switch (scope) + { + case "today": + (startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday); + days = 2; + break; + case "yesterday": + (startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday.AddDays(-1)); + days = 3; + break; + case "date": + if (!TryReadDate(args, "date", out var date)) + return new { success = false, message = "查询指定日期时必须提供有效的 date" }; + (startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date); + days = DaysSince(startUtc.Value); + break; + case "range": + if (!TryReadDate(args, "start_date", out var startDate) || + !TryReadDate(args, "end_date", out var endDate) || endDate < startDate) + return new { success = false, message = "必须提供有效的 start_date 和 end_date,且结束日期不能早于开始日期" }; + startUtc = AiDateTime.BeijingDateWindowUtc(startDate).StartUtc; + endUtc = AiDateTime.BeijingDateWindowUtc(endDate).EndUtc; + days = DaysSince(startUtc.Value); + break; + default: + days = args.TryGetProperty("days", out var d) && d.TryGetInt32(out var parsedDays) + ? Math.Clamp(parsedDays, 1, 365) + : 7; + scope = "recent_days"; + break; + } + + var records = await healthRecords.GetRecordsAsync(userId, metricType, days, ct); + var filtered = records + .Where(record => !startUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) >= startUtc.Value) + .Where(record => !endUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) < endUtc.Value) + .Select(record => new + { + record.Id, + record.Type, + record.Systolic, + record.Diastolic, + record.Value, + record.Unit, + record.Source, + record.IsAbnormal, + recorded_at_beijing = AiDateTime.ToBeijingText(record.RecordedAt), + }) + .ToList(); + return new { success = true, scope, count = filtered.Count, records = filtered }; } private static async Task ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct) @@ -54,55 +124,39 @@ public static class CommonAgentHandler if (archive == null) return new { found = false }; return new { - found = true, archive.Diagnosis, archive.SurgeryType, - SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries, - archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, + found = true, + archive.Diagnosis, + archive.SurgeryType, + SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), + archive.Surgeries, + archive.Allergies, + archive.DietRestrictions, + archive.ChronicDiseases, + archive.FamilyHistory, }; } - public static readonly ToolDefinition ManageArchiveTool = new() + private static bool TryNormalizeMetricType(string? value, out string? normalized) { - Function = new() + normalized = value?.Trim().ToLowerInvariant() switch { - Name = "manage_archive", Description = "管理用户健康档案", - Parameters = new - { - type = "object", - properties = new - { - action = new { type = "string", description = "update_diagnosis/update_surgery/update_allergies/update_chronic_diseases/update_diet_restrictions/query" }, - diagnosis = new { type = "string" }, - surgery_type = new { type = "string" }, - surgery_date = new { type = "string" }, - allergies = new { type = "array", items = new { type = "string" } }, - chronic_diseases = new { type = "array", items = new { type = "string" } }, - diet_restrictions = new { type = "array", items = new { type = "string" } }, - }, - required = new[] { "action" } - } - } - }; - - public static Task ExecuteManageArchive( - IHealthArchiveService archives, - Guid userId, - JsonElement args, - CancellationToken ct = default) - { - var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; - 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); + null or "" or "all" or "全部" => null, + "bloodpressure" or "blood_pressure" or "血压" => "BloodPressure", + "heartrate" or "heart_rate" or "心率" or "脉搏" => "HeartRate", + "glucose" or "blood_glucose" or "血糖" => "Glucose", + "spo2" or "blood_oxygen" or "血氧" or "血氧饱和度" => "SpO2", + "weight" or "体重" => "Weight", + _ => "__invalid__", + }; + return normalized != "__invalid__"; } - private static IReadOnlyList? 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().ToList() - : null; + private static bool TryReadDate(JsonElement args, string name, out DateOnly date) + { + date = default; + return args.TryGetProperty(name, out var value) && DateOnly.TryParse(value.GetString(), out date); + } + + private static int DaysSince(DateTime startUtc) => + Math.Clamp((int)Math.Ceiling((DateTime.UtcNow - startUtc).TotalDays) + 1, 1, 3650); } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs index d94fa07..92456b9 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs @@ -15,7 +15,9 @@ public static class ExerciseAgentHandler type = "object", properties = new { - action = new { type = "string", description = "create/query/checkin" }, + action = new { type = "string", @enum = new[] { "create", "query", "checkin" }, description = "create/query/checkin" }, + scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "all_plans" }, description = "查询时必填:今日任务/指定日期任务/当前计划/即将开始/已结束/全部计划" }, + date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" }, start_date = new { type = "string", description = "开始日期 yyyy-MM-dd,默认今天" }, duration_days = new { type = "integer", description = "连续运动天数,如一周为7天" }, exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" }, @@ -33,15 +35,16 @@ public static class ExerciseAgentHandler public static async Task Execute( string toolName, JsonElement args, - AppDbContext db, Guid userId, - IExerciseService? exercises = null, + IExerciseService exercises, CancellationToken ct = default) { - if (toolName != "manage_exercise" || exercises == null) + if (toolName != "manage_exercise") return new { success = false, message = $"未知工具: {toolName}" }; - var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query"; + var action = args.TryGetProperty("action", out var actionValue) + ? actionValue.GetString()?.ToLowerInvariant() + : null; if (action == "create") { var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart) @@ -67,17 +70,83 @@ public static class ExerciseAgentHandler return success ? new { success = true } : new { success = false, message = "条目不存在" }; } - var plan = await exercises.GetLatestAsync(userId, ct); - return plan == null - ? new { found = false } - : new + if (action != "query") + return new { success = false, message = $"未知操作: {action}" }; + + var scope = args.TryGetProperty("scope", out var scopeValue) + ? scopeValue.GetString()?.ToLowerInvariant() + : null; + if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "all_plans")) + return 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 }), + success = false, + message = "查询运动计划时必须明确 scope:today、scheduled_date、current_plans、upcoming_plans、ended_plans 或 all_plans", }; + if (scope == "scheduled_date" && + (!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _))) + return new { success = false, message = "查询指定日期的运动安排时必须提供有效的 date" }; + var queryDate = scope == "scheduled_date" + ? DateOnly.Parse(args.GetProperty("date").GetString()!) + : AiDateTime.BeijingToday; + var isDateScope = scope is "today" or "scheduled_date"; + var plans = await exercises.ListAllAsync(userId, ct); + var overview = plans.Select(plan => new + { + plan_id = plan.Id, + plan.StartDate, + plan.EndDate, + phase = queryDate < plan.StartDate ? "upcoming" : queryDate > plan.EndDate ? "ended" : "active", + plan.ReminderTime, + total_items = plan.Items.Count, + completed_items = plan.Items.Count(item => item.IsCompleted), + returned_items = isDateScope + ? plan.Items.Count(item => item.ScheduledDate == queryDate) + : Math.Min(plan.Items.Count, 14), + items_truncated = !isDateScope && plan.Items.Count > 14, + items = plan.Items + .Where(item => !isDateScope || item.ScheduledDate == queryDate) + .OrderBy(item => item.ScheduledDate) + .Take(isDateScope ? int.MaxValue : 14) + .Select(item => new + { + item.Id, + item.ScheduledDate, + item.ExerciseType, + item.DurationMinutes, + item.IsCompleted, + item.IsRestDay, + completed_at_beijing = item.CompletedAt.HasValue ? AiDateTime.ToBeijingText(item.CompletedAt.Value) : null, + }) + .ToList(), + }); + var filtered = scope switch + { + "all_plans" => overview, + "current_plans" => overview.Where(plan => plan.phase == "active"), + "upcoming_plans" => overview + .Where(plan => plan.phase == "upcoming") + .OrderBy(plan => plan.StartDate), + "ended_plans" => overview + .Where(plan => plan.phase == "ended") + .OrderByDescending(plan => plan.EndDate), + _ => overview.Where(plan => plan.phase == "active" && plan.items.Count > 0), + }; + var result = filtered.ToList(); + var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).ToList(); + var nextPlan = plans.Where(plan => plan.StartDate > queryDate).OrderBy(plan => plan.StartDate).FirstOrDefault(); + return new + { + found = result.Count > 0, + query_date = queryDate.ToString("yyyy-MM-dd"), + scope, + count = result.Count, + all_tasks_completed = isDateScope + ? scheduledTasks.Count > 0 && scheduledTasks.All(item => item.IsCompleted) + : (bool?)null, + has_upcoming_plans = nextPlan != null, + next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"), + days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber, + plans = result, + }; } } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs index 4a0c613..308f29f 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs @@ -11,8 +11,9 @@ public static class HealthDataAgentHandler { Function = new() { - Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)", - Parameters = new { type = "object", properties = new { type = new { type = "string" }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" } }, required = new[] { "type" } } + Name = "record_health_data", + Description = "记录健康数据(血压/心率/血糖/血氧/体重)", + Parameters = new { type = "object", properties = new { type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight" } }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" }, recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" } }, required = new[] { "type" } } } }; @@ -34,8 +35,9 @@ public static class HealthDataAgentHandler if (metricType == null) return new { success = false, message = $"未知指标类型: {type}" }; - var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) - ? dt + var recordedAt = args.TryGetProperty("recorded_at", out var ra) && + AiDateTime.TryParseUserDateTime(ra.GetString(), out var parsedRecordedAt) + ? parsedRecordedAt : DateTime.UtcNow; var request = new HealthRecordUpsertRequest( diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs index cc3c868..bff17cb 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs @@ -12,19 +12,23 @@ public static class MedicationAgentHandler Function = new() { Name = "manage_medication", - Description = "用药管理(创建/查询/确认服药)", + Description = "用药管理(创建计划、按日期查询计划与逐顿服药状态、确认某一顿服药)", Parameters = new { type = "object", properties = new { - action = new { type = "string", description = "create/query/confirm" }, + action = new { type = "string", @enum = new[] { "create", "query", "confirm" }, description = "create/query/confirm" }, + scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "inactive_plans", "all_plans" }, description = "查询时必填:今日安排/指定日期安排/当前计划/即将开始/自然结束/已停用/全部计划" }, + date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" }, medication_id = new { type = "string", description = "药品 ID,确认服药时使用" }, + scheduled_time = new { type = "string", description = "要确认的计划服药时间 HH:mm" }, name = new { type = "string", description = "药品名称" }, dosage = new { type = "string", description = "剂量,如 100mg" }, - frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" }, + frequency = new { type = "string", @enum = new[] { "Daily", "TwiceDaily", "ThreeTimesDaily", "EveryOtherDay", "Weekly", "AsNeeded" }, description = "服药频率" }, time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" }, duration_days = new { type = "integer", description = "服用天数" }, + long_term = new { type = "boolean", description = "是否明确为长期服用;与 duration_days 二选一" }, start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" }, }, required = new[] { "action" } @@ -49,11 +53,11 @@ public static class MedicationAgentHandler private static async Task ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct) { - var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; + var action = args.TryGetProperty("action", out var a) ? a.GetString()?.ToLowerInvariant() : null; return action switch { "create" => await CreateMedication(medications, userId, args, ct), - "query" => await QueryMedications(medications, userId, ct), + "query" => await QueryMedications(medications, userId, args, ct), "confirm" => await ConfirmMedication(medications, userId, args, ct), _ => new { success = false, message = $"未知操作: {action}" } }; @@ -61,14 +65,19 @@ public static class MedicationAgentHandler private static async Task CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct) { + var validationError = ValidateWriteArguments(args); + if (validationError != null) + return new { success = false, message = validationError }; + 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 longTerm = args.TryGetProperty("long_term", out var longTermValue) && longTermValue.ValueKind == JsonValueKind.True; var startDate = ReadStartDate(args); // 结束日为包含式(IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1) - var endDate = durationDays > 0 ? startDate.AddDays(durationDays - 1) : (DateOnly?)null; + DateOnly? endDate = longTerm ? null : startDate.AddDays(durationDays - 1); var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest( name, @@ -91,27 +100,146 @@ public static class MedicationAgentHandler time = timeLabels, start_date = startDate.ToString("yyyy-MM-dd"), duration_days = durationDays, + long_term = longTerm, }; } - private static async Task QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct) + private static async Task QueryMedications( + IMedicationService medications, + Guid userId, + JsonElement args, + 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 }) }; + var scope = args.TryGetProperty("scope", out var scopeValue) + ? scopeValue.GetString()?.ToLowerInvariant() + : null; + if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "inactive_plans" or "all_plans")) + return new + { + success = false, + message = "查询用药时必须明确 scope:today、scheduled_date、current_plans、upcoming_plans、ended_plans、inactive_plans 或 all_plans", + }; + if (scope == "scheduled_date" && + (!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _))) + return new { success = false, message = "查询指定日期的用药安排时必须提供有效的 date" }; + var date = scope == "scheduled_date" + ? DateOnly.Parse(args.GetProperty("date").GetString()!) + : AiDateTime.BeijingToday; + var isDateScope = scope is "today" or "scheduled_date"; + var overview = await medications.GetAiOverviewAsync(userId, date, ct); + var plans = scope switch + { + "all_plans" => overview.ToList(), + "current_plans" => overview.Where(plan => plan.Phase == "active").ToList(), + "upcoming_plans" => overview + .Where(plan => plan.Phase == "upcoming") + .OrderBy(plan => plan.StartDate) + .ToList(), + "ended_plans" => overview + .Where(plan => plan.Phase == "ended") + .OrderByDescending(plan => plan.EndDate) + .ToList(), + "inactive_plans" => overview + .Where(plan => plan.Phase == "inactive") + .OrderByDescending(plan => plan.EndDate) + .ToList(), + _ => overview.Where(plan => plan.ScheduledOnDate).ToList(), + }; + var doseStatuses = plans.SelectMany(plan => plan.Doses).ToList(); + var nextPlan = overview + .Where(plan => plan.Phase == "upcoming" && plan.StartDate.HasValue) + .OrderBy(plan => plan.StartDate) + .FirstOrDefault(); + + return new + { + query_date = date.ToString("yyyy-MM-dd"), + scope, + count = plans.Count, + all_doses_completed = isDateScope + ? doseStatuses.Count > 0 && doseStatuses.All(dose => dose.Status == "taken") + : (bool?)null, + has_upcoming_plans = nextPlan != null, + next_start_date = nextPlan?.StartDate?.ToString("yyyy-MM-dd"), + days_until_next_start = nextPlan?.StartDate is DateOnly nextStart + ? nextStart.DayNumber - date.DayNumber + : (int?)null, + plans, + }; } private static async Task 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 = "药品不存在或今天已经打卡" }; + var validationError = ValidateWriteArguments(args); + if (validationError != null) + return new { success = false, message = validationError }; + + var medId = args.TryGetProperty("medication_id", out var mid) && mid.TryGetGuid(out var parsedMedicationId) + ? parsedMedicationId + : Guid.Empty; + if (!args.TryGetProperty("scheduled_time", out var timeValue) || + !TimeOnly.TryParse(timeValue.GetString(), out var scheduledTime)) + return new { success = false, message = "缺少要确认的计划服药时间" }; + + var success = await medications.ConfirmDoseAsync( + userId, + medId, + scheduledTime, + MedicationLogStatus.Taken, + ct); + return success == true + ? new { success = true, medication_id = medId, scheduled_time = scheduledTime.ToString("HH:mm") } + : new { success = false, message = success == null ? "药品不存在" : "该时间不属于今天的服药安排,或这一顿已经记录" }; + } + + public static string? ValidateWriteArguments(JsonElement args) + { + var action = args.TryGetProperty("action", out var actionValue) + ? actionValue.GetString()?.ToLowerInvariant() + : null; + if (action == "confirm") + { + if (!args.TryGetProperty("medication_id", out var medicationId) || !medicationId.TryGetGuid(out _)) + return "确认服药时缺少有效的药品 ID"; + if (!args.TryGetProperty("scheduled_time", out var scheduledTime) || !TimeOnly.TryParse(scheduledTime.GetString(), out _)) + return "确认服药时缺少具体的计划服药时间"; + return null; + } + + if (action != "create") return null; + if (!args.TryGetProperty("name", out var name) || string.IsNullOrWhiteSpace(name.GetString())) + return "创建用药计划前需要确认药品名称"; + if (!args.TryGetProperty("dosage", out var dosage) || string.IsNullOrWhiteSpace(dosage.GetString())) + return "创建用药计划前需要确认每次剂量"; + if (!args.TryGetProperty("frequency", out var frequencyValue) || + !Enum.TryParse(frequencyValue.GetString(), ignoreCase: true, out _)) + return "创建用药计划前需要确认服药频率"; + if (!args.TryGetProperty("time_of_day", out var times) || times.ValueKind != JsonValueKind.Array) + return "创建用药计划前需要确认具体服药时间"; + var timeValues = times.EnumerateArray().ToList(); + if (timeValues.Count == 0 || timeValues.Any(value => !TimeOnly.TryParse(value.GetString(), out _))) + return "创建用药计划前需要提供有效的具体服药时间"; + if (!args.TryGetProperty("start_date", out var startDate) || !DateOnly.TryParse(startDate.GetString(), out _)) + return "创建用药计划前需要确认开始日期"; + + var durationDays = 0; + var hasDuration = args.TryGetProperty("duration_days", out var duration) && + duration.TryGetInt32(out durationDays) && durationDays > 0; + var isLongTerm = args.TryGetProperty("long_term", out var longTerm) && longTerm.ValueKind == JsonValueKind.True; + if (hasDuration && durationDays > 3650) + return "服用天数不能超过3650天;长期服用请明确设置 long_term=true"; + if (hasDuration && isLongTerm) + return "服用天数和长期服用不能同时设置"; + if (!hasDuration && !isLongTerm) + return "创建用药计划前需要确认服用天数,或明确说明长期服用"; + return null; } private static MedicationFrequency ReadFrequency(JsonElement args) { var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily"; - return Enum.TryParse(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily; + return Enum.TryParse(frequencyStr, ignoreCase: true, out var frequency) ? frequency : MedicationFrequency.Daily; } private static DateOnly ReadStartDate(JsonElement args) diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/patient_read_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/patient_read_agent_handler.cs new file mode 100644 index 0000000..02368ab --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/patient_read_agent_handler.cs @@ -0,0 +1,257 @@ +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] + "…"; +} diff --git a/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs index 939c23b..c0f12dd 100644 --- a/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs +++ b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs @@ -1,9 +1,12 @@ using System.Text.Json; using Health.Application.AI; using Health.Application.Exercises; +using Health.Application.Diets; using Health.Application.HealthArchives; using Health.Application.HealthRecords; using Health.Application.Medications; +using Health.Application.Notifications; +using Health.Application.Reports; using Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI; @@ -15,6 +18,9 @@ public sealed class AiToolExecutionService( IHealthRecordService healthRecords, IExerciseService exercises, IMedicationService medications, + IDietService diets, + IReportService reports, + IInAppNotificationService notifications, IAiWriteConfirmationStore confirmations) : IAiToolExecutionService { private readonly AppDbContext _db = db; @@ -23,6 +29,9 @@ public sealed class AiToolExecutionService( private readonly IHealthRecordService _healthRecords = healthRecords; private readonly IExerciseService _exercises = exercises; private readonly IMedicationService _medications = medications; + private readonly IDietService _diets = diets; + private readonly IReportService _reports = reports; + private readonly IInAppNotificationService _notifications = notifications; private readonly IAiWriteConfirmationStore _confirmations = confirmations; public async Task ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct) @@ -36,8 +45,11 @@ public sealed class AiToolExecutionService( "check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), "manage_medication" => MedicationAgentHandler.Execute(toolName, root, 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), + "manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, userId, _exercises, ct), + "query_diet_records" => PatientReadAgentHandler.QueryDietAsync(_diets, userId, root, ct), + "query_followups" => PatientReadAgentHandler.QueryFollowUpsAsync(_db, userId, root, ct), + "query_reports" => PatientReadAgentHandler.QueryReportsAsync(_reports, userId, root, ct), + "query_notifications" => PatientReadAgentHandler.QueryNotificationsAsync(_notifications, userId, root, ct), _ => Task.FromResult(new { success = false, message = $"未知工具: {toolName}" }), }); } diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index 5fef6f5..3d928cb 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -126,7 +126,10 @@ public sealed class PromptManager 你是一个用药记录与提醒助手。 规则: - 1. 用户询问当前用药时,必须先调用 manage_medication(action="query") 查询已有用药记录 + 1. 用户询问当前、某日、未来或过去的用药计划及服药完成状态时,必须先调用 manage_medication(action="query") 查询,不能根据患者背景或聊天历史猜测 + - 某一天的服药任务:今天用 scope="today";其他相对日期或指定日期用 scope="scheduled_date" 并传北京时间 date + - 计划状态:当前有效用 scope="current_plans";尚未开始用 scope="upcoming_plans";自然结束用 scope="ended_plans";人为停用用 scope="inactive_plans";全部用 scope="all_plans" + - 查询时必须明确传 scope;时间意图无法判断时先追问,不能默认成今天 2. 解析用户口中的药品信息(药名/剂量/频次/时间) 3. "早饭后"等模糊时间→追问具体几点 4. 解析完成后调用 manage_medication(action="create") 生成待确认命令并展示确认卡片;用户点击确认前不得声称已保存 @@ -154,6 +157,10 @@ public sealed class PromptManager 3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等 4. 运动强度要循序渐进 5. 避免剧烈运动 + 6. 查询运动计划时必须调用 manage_exercise(action="query"),不能根据聊天历史猜测 + - 某一天的运动任务:今天用 scope="today";其他相对日期或指定日期用 scope="scheduled_date" 并传北京时间 date + - 计划状态:当前有效用 scope="current_plans";尚未开始用 scope="upcoming_plans";已经结束用 scope="ended_plans";全部用 scope="all_plans" + - 查询时必须明确传 scope;时间意图无法判断时先追问,不能默认成今天 """; private const string UnifiedPrompt = """ @@ -163,11 +170,25 @@ public sealed class PromptManager 领域判断与可用工具: 1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data - 2. 饮食分析 → 引导用户使用「拍饮食」功能拍照识别 + 2. 新饮食图片分析 → 引导用户使用「拍饮食」功能;已保存饮食记录查询 → 调用 query_diet_records 3. 用药管理 → 调用 manage_medication 4. 运动计划 → 调用 manage_exercise - 5. 健康档案查询/修改 → 调用 check_archive / manage_archive + 5. 健康档案查询 → 调用 check_archive;聊天中不修改健康档案 6. 历史数据查询 → 调用 query_health_records + 7. 复查随访 → 调用 query_followups + 8. 已保存的检查报告 → 调用 query_reports + 9. 站内通知 → 调用 query_notifications + + 计划类问题统一采用“时间意图 + 业务对象”判断,不依赖某一句固定问法或某个单独关键词: + - 先判断用户是在查询“某一天的任务”,还是查询“计划所处的生命周期”。两者不能混用 + - 某一天的任务:北京时间今天使用 scope="today";昨天、明天、相对日期或明确日期使用 scope="scheduled_date" 并传换算后的 date + - 计划生命周期:当前有效使用 scope="current_plans";尚未开始使用 scope="upcoming_plans";已经自然结束使用 scope="ended_plans";查询全部历史和未来使用 scope="all_plans" + - 用药计划还要区分人为停用:明确询问已停药、已禁用或已取消的计划时使用 scope="inactive_plans",不能与自然到期混为一类 + - 复查使用同一语义映射:下一次 scope="next"、全部未来 scope="upcoming"、已完成 scope="completed"、指定日期 scope="date"、全部 scope="all" + - “有没有、会不会有、新的、接下来”等表达要结合完整语义判断:询问是否存在计划属于 query;明确要求新增、制定或安排才属于 create + - “今天是否完成”必须先查询当天任务,再根据逐项状态回答;计划存在、当天有任务和任务已完成是三个不同事实 + - 调用用药、运动或复查查询时 scope 必须明确传入;如果用户的时间意图确实无法判断,先只追问时间范围,禁止擅自按今天查询 + - 工具返回 results_truncated=true 或 items_truncated=true 时,只能说明当前返回的是部分结果或部分日程,不能把它表述成完整清单 运动计划参数格式(manage_exercise): - action="create", start_date="开始日期(YYYY-MM-DD,默认今天)" @@ -178,10 +199,21 @@ public sealed class PromptManager - 结束日期由 start_date + duration_days - 1 自动计算,不要按星期几生成条目 用药管理参数格式(manage_medication): - - action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7 + - action="create", name="药名", dosage="每次剂量", frequency="Daily", time_of_day=["08:00","20:00"], start_date="YYYY-MM-DD" + - 有明确疗程时传 duration_days;明确为长期服用时传 long_term=true。两者都不明确时必须追问 + - 药名、每次剂量、频率、具体时间、开始日期以及疗程/长期属性任一缺失时,必须只追问缺失信息,禁止使用默认值生成确认卡 + - 确认服药:必须使用查询结果中的 medication_id 和 scheduled_time,不能把当前时间当作计划时间 规则: - 用户可能一句话涉及多个领域,全部处理 + - 询问“我的、今天、当前、下一次、是否完成”等用户个人事实时,必须先调用对应只读工具。不能只根据患者背景或聊天历史回答 + - 当前工具结果是最高优先级;工具结果与历史聊天冲突时,以当前工具结果为准。历史里的计划、报告和完成状态不能当作当前事实 + - 患者背景不提供动态计划状态;任何用药、运动和复查计划事实都必须调用对应工具实时查询 + - 必须区分“记录存在”“计划当前有效”“指定日期有安排”“指定任务已完成”,不能把它们当成同一件事 + - 日期、今天、昨天、当前时间都按系统提供的北京时间解释 + - 工具没有返回数据时应明确说未查到;没有相应读取能力时应说明暂时无法查询,禁止编造 + - 查询健康记录时,根据用户问题选择最小且最相关的指标和时间范围:问某一指标就只查该指标;“今天/昨天”使用对应 scope;趋势问题按用户提到的7天、30天等范围查询;用户没有说明时间范围时默认 recent_days=7 + - 健康记录工具最多返回最近100条,这是有意的上限;不得为了获得更多原始记录而反复扩大或拆分查询。需要更长周期但现有结果不足时,应说明当前只能基于最近结果判断 - 先判断用户是在咨询数值,还是希望记录数据,不能仅凭消息中出现指标和数值就自动录入 - 明确提问时先回答问题,不调用 record_health_data。例如“血氧98%是不是太高了”是在咨询,应解释正常范围,不生成录入确认卡片;除非用户同时明确要求记录 - 明确要求“记录、录入、保存、记一下”时调用 record_health_data。例如“帮我记录血压116/89”必须生成待确认命令 diff --git a/backend/src/Health.Infrastructure/Exercises/EfExerciseRepository.cs b/backend/src/Health.Infrastructure/Exercises/EfExerciseRepository.cs index 2f3d101..12ea7eb 100644 --- a/backend/src/Health.Infrastructure/Exercises/EfExerciseRepository.cs +++ b/backend/src/Health.Infrastructure/Exercises/EfExerciseRepository.cs @@ -19,16 +19,16 @@ public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository .Take(limit) .ToListAsync(ct); + public async Task> ListAllAsync(Guid userId, CancellationToken ct) => + await _db.ExercisePlans.Include(p => p.Items) + .Where(p => p.UserId == userId) + .OrderByDescending(p => p.StartDate) + .ToListAsync(ct); + public Task GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => _db.ExercisePlans.Include(p => p.Items) .FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct); - public Task GetLatestAsync(Guid userId, CancellationToken ct) => - _db.ExercisePlans.Include(p => p.Items) - .Where(p => p.UserId == userId) - .OrderByDescending(p => p.StartDate) - .FirstOrDefaultAsync(ct); - public Task GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => _db.ExercisePlanItems.Include(i => i.Plan) .FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct); diff --git a/backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs b/backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs index 7bb5dcf..0b1da70 100644 --- a/backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs +++ b/backend/src/Health.Infrastructure/Medications/EfMedicationRepository.cs @@ -30,15 +30,6 @@ public sealed class EfMedicationRepository(AppDbContext db) : IMedicationReposit public Task GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => _db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct); - public Task ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => - _db.Medications.AnyAsync(m => m.Id == medicationId && m.UserId == userId, ct); - - public Task GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => - _db.MedicationLogs.FirstOrDefaultAsync(l => - l.MedicationId == medicationId && l.UserId == userId - && l.CreatedAt >= startUtc && l.CreatedAt < endUtc - && l.Status == MedicationLogStatus.Taken, ct); - public Task DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => _db.MedicationLogs.AnyAsync(l => l.MedicationId == medicationId && l.UserId == userId diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index 6a49d26..34b8e94 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -37,6 +37,7 @@ public static class AiChatEndpoints FastGptKnowledgeClient knowledgeClient, IAiToolExecutionService toolExecution, IAiWriteConfirmationStore confirmations, + IMedicationService medications, IAiConversationService conversations, IAttachmentContextBuilder attachments, IPatientContextService patientContexts, @@ -139,7 +140,11 @@ public static class AiChatEndpoints knowledgeSnippet = await knowledgeClient.SearchAsync(message, ct); } - var enhancedSystem = systemPrompt + "\n\n当前患者信息:\n" + patientContext; + var beijingNow = DateTime.UtcNow.AddHours(8); + var enhancedSystem = systemPrompt + + $"\n\n系统当前北京时间:{beijingNow:yyyy-MM-dd HH:mm:ss}(UTC+8)。所有‘今天/昨天/当前/下一次’均以此时间为准。" + + "\n\n当前患者背景信息(仅作医学背景;个人计划和完成状态必须调用对应工具获取最新数据):\n" + + patientContext; if (!string.IsNullOrWhiteSpace(knowledgeSnippet)) { enhancedSystem += "\n\n知识库参考资料(如与用户问题相关请优先采用):\n" + knowledgeSnippet; @@ -243,7 +248,7 @@ public static class AiChatEndpoints try { toolResult = IsWriteToolCall(tc.Function.Name, tc.Function.Arguments) - ? await PreparePendingWriteAsync(confirmations, userId.Value, tc.Function.Name, tc.Function.Arguments, ct) + ? await PreparePendingWriteAsync(confirmations, medications, userId.Value, tc.Function.Name, tc.Function.Arguments, ct) : await toolExecution.ExecuteAsync(tc.Function.Name, tc.Function.Arguments, userId.Value, ct); } catch (Exception ex) @@ -559,6 +564,10 @@ public static class AiChatEndpoints MedicationAgentHandler.ManageMedicationTool, ExerciseAgentHandler.ManageExerciseTool, CommonAgentHandler.CheckArchiveTool, + PatientReadAgentHandler.QueryDietRecordsTool, + PatientReadAgentHandler.QueryFollowUpsTool, + PatientReadAgentHandler.QueryReportsTool, + PatientReadAgentHandler.QueryNotificationsTool, ], _ => CommonAgentHandler.Tools, }; @@ -566,7 +575,6 @@ public static class AiChatEndpoints private static bool IsWriteToolCall(string toolName, string arguments) { 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; @@ -589,14 +597,39 @@ public static class AiChatEndpoints private static async Task PreparePendingWriteAsync( IAiWriteConfirmationStore confirmations, + IMedicationService medications, 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; + AiMedicationPlanDto? medicationPlan = null; + if (toolName == "manage_medication") + { + var validationError = MedicationAgentHandler.ValidateWriteArguments(args); + if (validationError != null) + return new { success = false, message = validationError }; + + if (string.Equals(GetString(args, "action"), "confirm", StringComparison.OrdinalIgnoreCase)) + { + var medicationId = args.GetProperty("medication_id").GetGuid(); + var scheduledTime = GetString(args, "scheduled_time")!; + var overview = await medications.GetAiOverviewAsync( + userId, + DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), + ct); + medicationPlan = overview.FirstOrDefault(plan => + plan.Id == medicationId && + plan.ScheduledOnDate && + plan.Doses.Any(dose => dose.ScheduledTime == scheduledTime && dose.Status is "scheduled" or "overdue")); + if (medicationPlan == null) + return new { success = false, message = "没有找到这顿尚未确认的今日服药安排,请重新查询后再确认" }; + } + } + + var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct); var preview = new Dictionary { @@ -613,12 +646,15 @@ public static class AiChatEndpoints 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"); + var medicationAction = GetString(args, "action"); + preview["name"] = medicationPlan?.Name ?? GetString(args, "name") ?? "用药计划"; + preview["operation"] = medicationAction ?? "create"; + preview["dosage"] = medicationPlan?.Dosage ?? GetString(args, "dosage") ?? ""; + preview["frequency"] = medicationPlan?.Frequency ?? GetString(args, "frequency") ?? ""; + preview["time"] = GetString(args, "scheduled_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"); + preview["long_term"] = args.TryGetProperty("long_term", out var longTerm) && longTerm.ValueKind == JsonValueKind.True; + preview["start_date"] = medicationPlan?.StartDate?.ToString("yyyy-MM-dd") ?? GetString(args, "start_date") ?? ""; break; case "manage_exercise": preview["type"] = "exercise"; @@ -628,11 +664,6 @@ public static class AiChatEndpoints 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; @@ -805,6 +836,8 @@ public static class AiChatEndpoints { if (resultDict.TryGetValue("name", out var name)) metadata["name"] = name?.ToString() ?? ""; + if (resultDict.TryGetValue("operation", out var operation)) + metadata["operation"] = operation?.ToString() ?? "create"; if (resultDict.TryGetValue("dosage", out var dosage)) metadata["dosage"] = dosage?.ToString() ?? ""; if (resultDict.TryGetValue("time", out var time)) @@ -834,15 +867,6 @@ public static class AiChatEndpoints 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; diff --git a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs index 567a803..a135130 100644 --- a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs @@ -42,15 +42,6 @@ public static class MedicationEndpoints : Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" }); }); - group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) => - { - var userId = GetUserId(http); - var taken = await medications.ToggleTodayTakenAsync(userId, id, ct); - if (taken == null) return Results.Ok(new { code = 404, message = "药品不存在" }); - - return Results.Ok(new { code = 0, data = new { taken }, message = (string?)null }); - }); - group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) => { var userId = GetUserId(http); diff --git a/backend/tests/Health.Tests/application_service_tests.cs b/backend/tests/Health.Tests/application_service_tests.cs index 756d727..c387b97 100644 --- a/backend/tests/Health.Tests/application_service_tests.cs +++ b/backend/tests/Health.Tests/application_service_tests.cs @@ -6,6 +6,8 @@ using Health.Application.Exercises; using Health.Domain; using Health.Domain.Entities; using Health.Domain.Enums; +using Health.Infrastructure.AI.AgentHandlers; +using System.Text.Json; namespace Health.Tests; @@ -270,6 +272,73 @@ public sealed class ApplicationServiceTests Assert.Equal(2, current.Items.Count); } + [Fact] + public async Task AiExerciseQuery_UpcomingScopeReturnsFuturePlanButTodayScopeDoesNot() + { + var userId = Guid.NewGuid(); + var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + var repository = new FakeExerciseRepository(); + var plan = new ExercisePlan + { + Id = Guid.NewGuid(), + UserId = userId, + StartDate = today.AddDays(2), + EndDate = today.AddDays(4), + ReminderTime = new TimeOnly(19, 0), + }; + plan.Items.Add(new ExercisePlanItem + { + Id = Guid.NewGuid(), + ScheduledDate = today.AddDays(2), + ExerciseType = "散步", + DurationMinutes = 30, + }); + repository.SetPlan(plan); + var service = new ExerciseService(repository); + using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}"""); + using var todayArgs = JsonDocument.Parse("""{"action":"query","scope":"today"}"""); + + var upcoming = await ExerciseAgentHandler.Execute( + "manage_exercise", upcomingArgs.RootElement, userId, service, CancellationToken.None); + var todayResult = await ExerciseAgentHandler.Execute( + "manage_exercise", todayArgs.RootElement, userId, service, CancellationToken.None); + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions)); + using var todayJson = JsonDocument.Parse(JsonSerializer.Serialize(todayResult, jsonOptions)); + + Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32()); + Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32()); + Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32()); + } + + [Fact] + public async Task AiExerciseQuery_RequiresExplicitScopeInsteadOfAssumingToday() + { + var userId = Guid.NewGuid(); + var service = new ExerciseService(new FakeExerciseRepository()); + using var args = JsonDocument.Parse("""{"action":"query"}"""); + + var result = await ExerciseAgentHandler.Execute( + "manage_exercise", args.RootElement, userId, service, CancellationToken.None); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result)); + + Assert.False(json.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("scope", json.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task AiFollowUpQuery_RequiresExplicitScopeInsteadOfAssumingNext() + { + using var args = JsonDocument.Parse("""{}"""); + + var result = await PatientReadAgentHandler.QueryFollowUpsAsync( + null!, Guid.NewGuid(), args.RootElement, CancellationToken.None); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result)); + + Assert.False(json.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("scope", json.RootElement.GetProperty("message").GetString()); + } + [Fact] public async Task ExercisePlan_CheckInRejectsNonTodayItem() { @@ -378,9 +447,11 @@ public sealed class ApplicationServiceTests public void SetPlan(ExercisePlan plan) => Plan = plan; public Task> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult>( Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []); - public Task> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult>([]); + public Task> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult>( + Plan != null && Plan.UserId == userId ? [Plan] : []); + public Task> ListAllAsync(Guid userId, CancellationToken ct) => Task.FromResult>( + Plan != null && Plan.UserId == userId ? [Plan] : []); public Task GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan); - public Task GetLatestAsync(Guid userId, CancellationToken ct) => Task.FromResult(Plan); public Task GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId)); public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; } public void Delete(ExercisePlan plan) { Plan = null; } diff --git a/backend/tests/Health.Tests/medication_update_tests.cs b/backend/tests/Health.Tests/medication_update_tests.cs index 0cce20b..0231728 100644 --- a/backend/tests/Health.Tests/medication_update_tests.cs +++ b/backend/tests/Health.Tests/medication_update_tests.cs @@ -1,5 +1,8 @@ using Health.Application.Medications; using Health.Domain.Entities; +using Health.Domain.Enums; +using Health.Infrastructure.AI.AgentHandlers; +using System.Text.Json; namespace Health.Tests; @@ -32,21 +35,227 @@ public sealed class MedicationUpdateTests Assert.True(repository.Saved); } - private sealed class FakeMedicationRepository(Medication medication) : IMedicationRepository + [Fact] + public async Task AiOverview_DoesNotScheduleFutureMedicationForToday() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + var medication = new Medication + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Name = "未来用药", + IsActive = true, + StartDate = today.AddDays(2), + EndDate = today.AddDays(10), + Frequency = MedicationFrequency.Daily, + TimeOfDay = [new TimeOnly(8, 0)], + }; + var service = new MedicationService(new FakeMedicationRepository(medication)); + + var overview = await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None); + + var plan = Assert.Single(overview); + Assert.Equal("upcoming", plan.Phase); + Assert.False(plan.ScheduledOnDate); + Assert.Empty(plan.Doses); + } + + [Fact] + public async Task AiOverview_ReportsEachDoseInsteadOfAnyTakenAggregate() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + var medication = new Medication + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Name = "两顿药", + IsActive = true, + StartDate = today.AddDays(-1), + EndDate = today.AddDays(1), + Frequency = MedicationFrequency.TwiceDaily, + TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)], + }; + var log = new MedicationLog + { + Id = Guid.NewGuid(), + UserId = medication.UserId, + MedicationId = medication.Id, + ScheduledTime = new TimeOnly(8, 0), + Status = MedicationLogStatus.Taken, + ConfirmedAt = DateTime.UtcNow, + CreatedAt = DateTime.UtcNow, + }; + var service = new MedicationService(new FakeMedicationRepository(medication, [log])); + + var plan = Assert.Single(await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None)); + + Assert.Equal(2, plan.Doses.Count); + Assert.Equal("taken", plan.Doses.Single(dose => dose.ScheduledTime == "08:00").Status); + Assert.NotEqual("taken", plan.Doses.Single(dose => dose.ScheduledTime == "20:00").Status); + } + + [Fact] + public async Task ConfirmDose_RejectsAClockTimeThatIsNotInTodaysPlan() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + var medication = new Medication + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Name = "阿司匹林", + IsActive = true, + StartDate = today, + Frequency = MedicationFrequency.Daily, + TimeOfDay = [new TimeOnly(8, 0)], + }; + var repository = new FakeMedicationRepository(medication); + var service = new MedicationService(repository); + + var result = await service.ConfirmDoseAsync( + medication.UserId, + medication.Id, + new TimeOnly(8, 15), + MedicationLogStatus.Taken, + CancellationToken.None); + + Assert.False(result); + Assert.Empty(repository.AddedLogs); + } + + [Fact] + public void AiMedicationCreate_RejectsMissingFieldsInsteadOfUsingDefaults() + { + using var arguments = JsonDocument.Parse("""{"action":"create","name":"阿司匹林"}"""); + + var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement); + + Assert.Equal("创建用药计划前需要确认每次剂量", error); + } + + [Fact] + public void AiMedicationCreate_AcceptsCompleteFinitePlan() + { + using var arguments = JsonDocument.Parse(""" + { + "action":"create", + "name":"阿司匹林", + "dosage":"100mg", + "frequency":"Daily", + "time_of_day":["08:00"], + "start_date":"2026-07-20", + "duration_days":30 + } + """); + + var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement); + + Assert.Null(error); + } + + [Fact] + public async Task AiMedicationQuery_UpcomingScopeReturnsTomorrowPlanButCurrentScopeDoesNot() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + var medication = new Medication + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Name = "明天开始的药", + Dosage = "1片", + IsActive = true, + StartDate = today.AddDays(1), + Frequency = MedicationFrequency.Daily, + TimeOfDay = [new TimeOnly(8, 0)], + }; + var service = new MedicationService(new FakeMedicationRepository(medication)); + using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}"""); + using var currentArgs = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}"""); + + var upcoming = await MedicationAgentHandler.Execute( + "manage_medication", upcomingArgs.RootElement, medication.UserId, service, CancellationToken.None); + var current = await MedicationAgentHandler.Execute( + "manage_medication", currentArgs.RootElement, medication.UserId, service, CancellationToken.None); + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions)); + using var currentJson = JsonDocument.Parse(JsonSerializer.Serialize(current, jsonOptions)); + + Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32()); + Assert.True(upcomingJson.RootElement.GetProperty("has_upcoming_plans").GetBoolean()); + Assert.Equal(1, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32()); + Assert.Equal("明天开始的药", upcomingJson.RootElement.GetProperty("plans")[0].GetProperty("name").GetString()); + Assert.Equal(0, currentJson.RootElement.GetProperty("count").GetInt32()); + } + + [Fact] + public async Task AiMedicationQuery_RequiresExplicitScopeInsteadOfAssumingToday() + { + var medication = new Medication + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Name = "测试药物", + IsActive = true, + StartDate = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), + Frequency = MedicationFrequency.Daily, + TimeOfDay = [new TimeOnly(8, 0)], + }; + var service = new MedicationService(new FakeMedicationRepository(medication)); + using var args = JsonDocument.Parse("""{"action":"query"}"""); + + var result = await MedicationAgentHandler.Execute( + "manage_medication", args.RootElement, medication.UserId, service, CancellationToken.None); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result)); + + Assert.False(json.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("scope", json.RootElement.GetProperty("message").GetString()); + } + + [Fact] + public async Task AiMedicationQuery_SeparatesInactivePlansFromNaturallyEndedPlans() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + var medication = new Medication + { + Id = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Name = "已停用药物", + IsActive = false, + StartDate = today.AddDays(-10), + EndDate = today.AddDays(10), + Frequency = MedicationFrequency.Daily, + TimeOfDay = [new TimeOnly(8, 0)], + }; + var service = new MedicationService(new FakeMedicationRepository(medication)); + using var inactiveArgs = JsonDocument.Parse("""{"action":"query","scope":"inactive_plans"}"""); + using var endedArgs = JsonDocument.Parse("""{"action":"query","scope":"ended_plans"}"""); + + var inactive = await MedicationAgentHandler.Execute( + "manage_medication", inactiveArgs.RootElement, medication.UserId, service, CancellationToken.None); + var ended = await MedicationAgentHandler.Execute( + "manage_medication", endedArgs.RootElement, medication.UserId, service, CancellationToken.None); + using var inactiveJson = JsonDocument.Parse(JsonSerializer.Serialize(inactive)); + using var endedJson = JsonDocument.Parse(JsonSerializer.Serialize(ended)); + + Assert.Equal(1, inactiveJson.RootElement.GetProperty("count").GetInt32()); + Assert.Equal(0, endedJson.RootElement.GetProperty("count").GetInt32()); + } + + private sealed class FakeMedicationRepository( + Medication medication, + IReadOnlyList? logs = null) : IMedicationRepository { public bool Saved { get; private set; } + public List AddedLogs { get; } = []; public Task> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult>([medication]); public Task> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult>([medication]); - public Task> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult>([]); + public Task> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(logs ?? (IReadOnlyList)[]); public Task GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId ? medication : null); - public Task ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId); - public Task GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(null); public Task DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false); public Task GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(null); public Task> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult>([medication]); public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask; - public Task AddLogAsync(MedicationLog log, CancellationToken ct) => Task.CompletedTask; + public Task AddLogAsync(MedicationLog log, CancellationToken ct) { AddedLogs.Add(log); return Task.CompletedTask; } public void DeleteMedication(Medication value) { } public void DeleteLog(MedicationLog log) { } public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; } diff --git a/backend/tests/Health.Tests/prompt_manager_tests.cs b/backend/tests/Health.Tests/prompt_manager_tests.cs index e965865..258ffaf 100644 --- a/backend/tests/Health.Tests/prompt_manager_tests.cs +++ b/backend/tests/Health.Tests/prompt_manager_tests.cs @@ -15,4 +15,33 @@ public sealed class PromptManagerTests Assert.Contains("先回答问题,不调用 record_health_data", prompt); Assert.Contains("帮我记录血压116/89", prompt); } + + [Fact] + public void UnifiedPrompt_RequiresFreshToolsForPersonalStatusAndKeepsArchiveReadOnly() + { + var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified); + + Assert.Contains("必须先调用对应只读工具", prompt); + Assert.Contains("当前工具结果是最高优先级", prompt); + Assert.Contains("聊天中不修改健康档案", prompt); + Assert.DoesNotContain("健康档案查询/修改", prompt); + } + + [Fact] + public void UnifiedPrompt_UsesACommonTemporalIntentModelForPlanQueries() + { + var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified); + + Assert.Contains("时间意图 + 业务对象", prompt); + Assert.Contains("某一天的任务", prompt); + Assert.Contains("计划所处的生命周期", prompt); + Assert.Contains("不依赖某一句固定问法或某个单独关键词", prompt); + Assert.Contains("scope=\"upcoming_plans\"", prompt); + Assert.Contains("scope=\"ended_plans\"", prompt); + Assert.Contains("scope=\"inactive_plans\"", prompt); + Assert.Contains("scope=\"scheduled_date\"", prompt); + Assert.Contains("复查使用同一语义映射", prompt); + Assert.Contains("scope 必须明确传入", prompt); + Assert.DoesNotContain("患者背景只包含当前日期范围内的部分计划", prompt); + } } diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index 82ff32a..95886b6 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -292,13 +292,7 @@ class _TrendPageState extends ConsumerState { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), - builder: (ctx) => Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(ctx).viewInsets.bottom, - left: 20, - right: 20, - top: 20, - ), + builder: (ctx) => _KeyboardInsetPadding( child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -415,6 +409,7 @@ class _TrendPageState extends ConsumerState { @override Widget build(BuildContext context) { return GradientScaffold( + resizeToAvoidBottomInset: false, appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), @@ -1304,6 +1299,25 @@ class _TrendPageState extends ConsumerState { } } +class _KeyboardInsetPadding extends StatelessWidget { + final Widget child; + + const _KeyboardInsetPadding({required this.child}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + left: 20, + right: 20, + top: 20, + ), + child: RepaintBoundary(child: child), + ); + } +} + class _StatisticItem extends StatelessWidget { final String label; final String value; diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index ad4f18f..32f7e40 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -8,6 +8,7 @@ import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/data_refresh_providers.dart'; import '../../widgets/app_toast.dart'; import '../../widgets/common_widgets.dart'; import '../../widgets/app_empty_state.dart'; @@ -803,6 +804,7 @@ class _DietCapturePageState extends ConsumerState { Future _saveDietRecord() async { try { await ref.read(dietProvider.notifier).saveRecord(); + ref.read(dietDataRefreshSignalProvider.notifier).trigger(); if (mounted) popRoute(ref); } on DietFoodValidationException catch (e) { if (mounted) { diff --git a/health_app/lib/pages/doctor/doctor_dashboard_page.dart b/health_app/lib/pages/doctor/doctor_dashboard_page.dart index 3a6b24f..bc4f1c5 100644 --- a/health_app/lib/pages/doctor/doctor_dashboard_page.dart +++ b/health_app/lib/pages/doctor/doctor_dashboard_page.dart @@ -3,11 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/backoffice_refresh_providers.dart'; import '../../widgets/backoffice_ui.dart'; import '../doctor/doctor_home_page.dart' show doctorPageProvider; final _dashboardProvider = FutureProvider?>((ref) async { ref.watch(userSessionIdentityProvider); + ref.watch(doctorDashboardRefreshSignalProvider); final api = ref.read(apiClientProvider); final res = await api.get('/api/doctor/dashboard'); return res.data['data'] as Map?; diff --git a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart index d10fbfc..1674924 100644 --- a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart +++ b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart @@ -274,6 +274,7 @@ class _DoctorFollowUpEditPageState } if (mounted) { ref.read(doctorFollowupsRefreshSignalProvider.notifier).trigger(); + ref.read(doctorDashboardRefreshSignalProvider.notifier).trigger(); _snack(isEdit ? '修改成功' : '创建成功', ok: true); popRoute(ref); } diff --git a/health_app/lib/pages/doctor/doctor_followups_page.dart b/health_app/lib/pages/doctor/doctor_followups_page.dart index 0d411bd..f4380dc 100644 --- a/health_app/lib/pages/doctor/doctor_followups_page.dart +++ b/health_app/lib/pages/doctor/doctor_followups_page.dart @@ -211,6 +211,12 @@ class _DoctorFollowupsPageState extends ConsumerState { .markDone( f['id']?.toString() ?? '', ); + ref + .read( + doctorDashboardRefreshSignalProvider + .notifier, + ) + .trigger(); }, child: const Text( '完成', @@ -232,6 +238,12 @@ class _DoctorFollowupsPageState extends ConsumerState { .remove( f['id']?.toString() ?? '', ); + ref + .read( + doctorDashboardRefreshSignalProvider + .notifier, + ) + .trigger(); }, child: const Text( '删除', diff --git a/health_app/lib/pages/doctor/doctor_profile_page.dart b/health_app/lib/pages/doctor/doctor_profile_page.dart index 4056ff2..2d2d6fa 100644 --- a/health_app/lib/pages/doctor/doctor_profile_page.dart +++ b/health_app/lib/pages/doctor/doctor_profile_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/backoffice_refresh_providers.dart'; import '../../widgets/app_toast.dart'; import '../../widgets/backoffice_ui.dart'; @@ -120,6 +121,7 @@ class _DoctorProfileEditPageState extends ConsumerState { }, ); ref.invalidate(_docProfileProvider); + ref.read(doctorDashboardRefreshSignalProvider.notifier).trigger(); if (mounted) { AppToast.show(context, '保存成功', type: AppToastType.success); } diff --git a/health_app/lib/pages/doctor/doctor_report_detail_page.dart b/health_app/lib/pages/doctor/doctor_report_detail_page.dart index 2550cc1..65b94ff 100644 --- a/health_app/lib/pages/doctor/doctor_report_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_report_detail_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/backoffice_refresh_providers.dart'; import '../../utils/backoffice_formatters.dart'; import '../../widgets/app_toast.dart'; import '../../widgets/backoffice_ui.dart'; @@ -75,6 +76,8 @@ class _DoctorReportDetailPageState if (!mounted) return; setState(() => _submitted = true); ref.invalidate(_reportDetailProvider(widget.id)); + ref.read(doctorReportsRefreshSignalProvider.notifier).trigger(); + ref.read(doctorDashboardRefreshSignalProvider.notifier).trigger(); if (mounted) { AppToast.show(context, '审核提交成功', type: AppToastType.success); } diff --git a/health_app/lib/pages/doctor/doctor_reports_page.dart b/health_app/lib/pages/doctor/doctor_reports_page.dart index f72274b..8d33234 100644 --- a/health_app/lib/pages/doctor/doctor_reports_page.dart +++ b/health_app/lib/pages/doctor/doctor_reports_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/backoffice_refresh_providers.dart'; import '../../utils/backoffice_formatters.dart'; import '../../widgets/backoffice_ui.dart'; @@ -10,6 +11,7 @@ final _reportsProvider = FutureProvider>>(( ref, ) async { ref.watch(userSessionIdentityProvider); + ref.watch(doctorReportsRefreshSignalProvider); final api = ref.read(apiClientProvider); final res = await api.get('/api/doctor/reports'); return (res.data['data'] as List?)?.cast>() ?? []; diff --git a/health_app/lib/pages/exercise/exercise_plan_page.dart b/health_app/lib/pages/exercise/exercise_plan_page.dart index a92a56b..de0d055 100644 --- a/health_app/lib/pages/exercise/exercise_plan_page.dart +++ b/health_app/lib/pages/exercise/exercise_plan_page.dart @@ -8,6 +8,7 @@ import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; +import '../../providers/data_refresh_providers.dart'; import '../../widgets/app_empty_state.dart'; import '../../widgets/app_future_view.dart'; import '../../widgets/app_toast.dart'; @@ -34,8 +35,7 @@ class _EnterpriseExercisePlanPageState setState(() => _busyItems.add(itemId)); try { await ref.read(exerciseServiceProvider).checkIn(itemId); - ref.invalidate(currentExercisePlanProvider); - ref.invalidate(exercisePlansProvider); + ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); } catch (error) { if (mounted) { AppToast.show( @@ -73,8 +73,7 @@ class _EnterpriseExercisePlanPageState setState(() => _deletingPlans.add(id)); try { await ref.read(exerciseServiceProvider).deletePlan(id); - ref.invalidate(currentExercisePlanProvider); - ref.invalidate(exercisePlansProvider); + ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); if (mounted) { AppToast.show(context, '运动计划已删除', type: AppToastType.success); } @@ -421,12 +420,12 @@ class _ExercisePlanRow extends StatelessWidget { width: 40, height: 40, decoration: BoxDecoration( - color: AppColors.exerciseLight, + color: _phaseColor(phase).withValues(alpha: 0.10), borderRadius: AppRadius.smBorder, ), child: Icon( AppModuleVisuals.exercise.icon, - color: AppColors.exercise, + color: _phaseColor(phase), size: 22, ), ), diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index b7c4b3f..1f37782 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -170,18 +170,30 @@ class _HomePageState extends ConsumerState children: [ _buildHeader(user), Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragStart: _startConversationDrawerDrag, - onHorizontalDragUpdate: _updateConversationDrawerDrag, - onHorizontalDragEnd: _endConversationDrawerDrag, - onHorizontalDragCancel: () => _conversationDragDistance = 0, - child: RepaintBoundary( - child: _HomeMessages(scrollCtrl: _scrollCtrl), + child: ClipRect( + child: KeyboardTranslate( + child: Column( + children: [ + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: _startConversationDrawerDrag, + onHorizontalDragUpdate: + _updateConversationDrawerDrag, + onHorizontalDragEnd: _endConversationDrawerDrag, + onHorizontalDragCancel: () => + _conversationDragDistance = 0, + child: RepaintBoundary( + child: _HomeMessages(scrollCtrl: _scrollCtrl), + ), + ), + ), + _buildBottomBar(context), + ], + ), ), ), ), - KeyboardLift(child: _buildBottomBar(context)), ], ), ), diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 36c76fc..69e5fe3 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -420,6 +420,8 @@ class ChatMessagesView extends ConsumerWidget { ) { final meta = msg.metadata ?? {}; final backendType = meta['type'] as String? ?? ''; + final medicationOperation = meta['operation']?.toString() ?? 'create'; + final isMedicationCheckIn = medicationOperation == 'confirm'; final screenWidth = MediaQuery.sizeOf(context).width; // 识别是哪种数据类型 @@ -449,7 +451,7 @@ class ChatMessagesView extends ConsumerWidget { if (isMedication) { // 药品录入 — 主展示区已显示药名+剂量,详情只列额外信息 - title = '药品录入确认'; + title = isMedicationCheckIn ? '服药打卡确认' : '药品录入确认'; titleIcon = Icons.medication_liquid_outlined; mainLabel = meta['name'] as String? ?? ''; mainValue = meta['dosage'] as String? ?? ''; @@ -834,7 +836,9 @@ class ChatMessagesView extends ConsumerWidget { ), const SizedBox(width: 8), Text( - msg.isReadOnly ? '历史记录' : '录入成功', + msg.isReadOnly + ? '历史记录' + : (isMedicationCheckIn ? '打卡成功' : '录入成功'), style: const TextStyle( fontSize: 19, fontWeight: FontWeight.w700, @@ -882,9 +886,9 @@ class ChatMessagesView extends ConsumerWidget { ), ), const SizedBox(width: 10), - const Text( - '确认录入', - style: TextStyle( + Text( + isMedicationCheckIn ? '确认打卡' : '确认录入', + style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -1005,7 +1009,7 @@ class ChatMessagesView extends ConsumerWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: isUser ? const Color(0xFF5F56FF) : const Color(0xFFF8F8FF), + color: isUser ? const Color(0xFF5F56FF) : const Color(0xFFFAFAFF), borderRadius: BorderRadius.only( topLeft: Radius.circular(isUser ? 18.6 : 3), topRight: Radius.circular(isUser ? 3 : 18.6), diff --git a/health_app/lib/pages/medication/medication_checkin_page.dart b/health_app/lib/pages/medication/medication_checkin_page.dart index 88e9c84..ca4ef43 100644 --- a/health_app/lib/pages/medication/medication_checkin_page.dart +++ b/health_app/lib/pages/medication/medication_checkin_page.dart @@ -10,6 +10,7 @@ import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/data_providers.dart'; +import '../../providers/data_refresh_providers.dart'; import '../../widgets/app_empty_state.dart'; import '../../widgets/app_error_state.dart'; import '../../widgets/app_toast.dart'; @@ -62,8 +63,7 @@ class _MedicationCheckInPageState extends ConsumerState { throw const ApiException('该次服药已经打卡'); } } - ref.invalidate(medicationReminderProvider); - ref.invalidate(medicationListProvider); + ref.read(medicationDataRefreshSignalProvider.notifier).trigger(); _load(); } catch (error) { if (mounted) { diff --git a/health_app/lib/pages/medication/medication_edit_page.dart b/health_app/lib/pages/medication/medication_edit_page.dart index e13409c..cb29618 100644 --- a/health_app/lib/pages/medication/medication_edit_page.dart +++ b/health_app/lib/pages/medication/medication_edit_page.dart @@ -8,6 +8,7 @@ import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; +import '../../providers/data_refresh_providers.dart'; import '../../widgets/app_error_state.dart'; import '../../widgets/app_toast.dart'; import '../../widgets/common_widgets.dart'; @@ -117,8 +118,7 @@ class _MedicationEditPageState extends ConsumerState { } else { await service.create(payload); } - ref.invalidate(medicationListProvider); - ref.invalidate(medicationReminderProvider); + ref.read(medicationDataRefreshSignalProvider.notifier).trigger(); if (mounted) { AppToast.show( context, @@ -164,8 +164,7 @@ class _MedicationEditPageState extends ConsumerState { setState(() => _saving = true); try { await ref.read(medicationServiceProvider).deleteMedication(widget.id!); - ref.invalidate(medicationListProvider); - ref.invalidate(medicationReminderProvider); + ref.read(medicationDataRefreshSignalProvider.notifier).trigger(); if (mounted) { AppToast.show(context, '用药已删除', type: AppToastType.success); popRoute(ref); diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index 9bed7e4..cae266a 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -9,6 +9,7 @@ import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; +import '../../providers/data_refresh_providers.dart'; import '../../widgets/app_empty_state.dart'; import '../../widgets/app_future_view.dart'; import '../../widgets/app_toast.dart'; @@ -52,8 +53,7 @@ class _MedicationListPageState extends ConsumerState { setState(() => _deleting.add(id)); try { await ref.read(medicationServiceProvider).deleteMedication(id); - ref.invalidate(medicationListProvider); - ref.invalidate(medicationReminderProvider); + ref.read(medicationDataRefreshSignalProvider.notifier).trigger(); if (mounted) AppToast.show(context, '用药已删除', type: AppToastType.success); } catch (error) { if (mounted) { diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 1dcb9b8..6275af3 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -9,6 +9,7 @@ import '../core/app_theme.dart'; import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/data_providers.dart'; +import '../providers/data_refresh_providers.dart'; import '../widgets/common_widgets.dart'; import '../widgets/app_empty_state.dart'; import '../widgets/app_error_state.dart'; @@ -64,7 +65,7 @@ class _DietRecordListPageState extends ConsumerState { setState(() => _data.removeAt(index)); try { await ref.read(dietServiceProvider).deleteRecord(id); - ref.invalidate(dietRecordsProvider); + ref.read(dietDataRefreshSignalProvider.notifier).trigger(); if (mounted) { AppToast.show(context, '已删除', type: AppToastType.success); } @@ -100,6 +101,9 @@ class _DietRecordListPageState extends ConsumerState { @override Widget build(BuildContext context) { + ref.listen(dietDataRefreshSignalProvider, (previous, next) { + if (previous != null && previous != next) _refresh(); + }); if (_loading) { return GradientScaffold( appBar: AppBar( @@ -451,7 +455,9 @@ class _DietRecordListPageState extends ConsumerState { }); if (!ctx.mounted) return; Navigator.pop(ctx); - await _refresh(); + ref + .read(dietDataRefreshSignalProvider.notifier) + .trigger(); } catch (_) { if (!ctx.mounted) return; setDialogState(() => saving = false); @@ -1047,7 +1053,7 @@ class _ExercisePlanPageState extends ConsumerState { setState(() => _busyItems.add(itemId)); try { await ref.read(exerciseServiceProvider).checkIn(itemId); - ref.invalidate(currentExercisePlanProvider); + ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); _load(); } catch (e) { debugPrint('[ExercisePlan] 打卡失败: $e'); @@ -1061,7 +1067,7 @@ class _ExercisePlanPageState extends ConsumerState { Future _deletePlan(String id) async { await ref.read(exerciseServiceProvider).deletePlan(id); - ref.invalidate(currentExercisePlanProvider); + ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); _load(); } @@ -1657,7 +1663,7 @@ class _ExercisePlanDetailPageState setState(() => _busyItems.add(itemId)); try { await ref.read(exerciseServiceProvider).checkIn(itemId); - ref.invalidate(currentExercisePlanProvider); + ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); _load(); } catch (e) { debugPrint('[ExercisePlanDetail] 打卡失败: $e'); @@ -2182,7 +2188,7 @@ class _ExercisePlanCreatePageState 'reminderTime': '${_reminderTime.hour.toString().padLeft(2, '0')}:${_reminderTime.minute.toString().padLeft(2, '0')}', }); - ref.invalidate(currentExercisePlanProvider); + ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); if (mounted) { popRoute(ref); } @@ -3805,8 +3811,19 @@ class _HealthCalendarPageState extends ConsumerState { _loadMonth(); } + void _refreshScheduleData() { + _dayCache.clear(); + _loadMonth(); + } + @override Widget build(BuildContext context) { + ref.listen(exerciseDataRefreshSignalProvider, (previous, next) { + if (previous != null && previous != next) _refreshScheduleData(); + }); + ref.listen(medicationDataRefreshSignalProvider, (previous, next) { + if (previous != null && previous != next) _refreshScheduleData(); + }); return GradientScaffold( appBar: AppBar( backgroundColor: Colors.white, diff --git a/health_app/lib/providers/backoffice_refresh_providers.dart b/health_app/lib/providers/backoffice_refresh_providers.dart index c6637e1..c4ec84a 100644 --- a/health_app/lib/providers/backoffice_refresh_providers.dart +++ b/health_app/lib/providers/backoffice_refresh_providers.dart @@ -12,3 +12,9 @@ final adminDoctorsRefreshSignalProvider = final doctorFollowupsRefreshSignalProvider = NotifierProvider(BackofficeRefreshSignal.new); + +final doctorReportsRefreshSignalProvider = + NotifierProvider(BackofficeRefreshSignal.new); + +final doctorDashboardRefreshSignalProvider = + NotifierProvider(BackofficeRefreshSignal.new); diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 083bba6..d3c4344 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -5,10 +5,27 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'auth_provider.dart'; import 'conversation_history_provider.dart'; import 'data_providers.dart'; +import 'data_refresh_providers.dart'; import '../utils/sse_handler.dart'; enum MessageType { text, dataConfirm, agentWelcome, taskCard } +List prependTodayHealthCard( + List messages, { + DateTime? now, +}) { + return [ + ChatMessage( + id: 'task_card', + role: 'assistant', + content: '', + createdAt: now ?? DateTime.now(), + type: MessageType.taskCard, + ), + ...messages, + ]; +} + class ChatMessage { final String id; final String role; @@ -133,10 +150,9 @@ class ChatNotifier extends Notifier { msgs[i].confirmed = true; state = state.copyWith(messages: msgs); - ref.invalidate(medicationListProvider); - ref.invalidate(medicationReminderProvider); + ref.read(medicationDataRefreshSignalProvider.notifier).trigger(); ref.invalidate(latestHealthProvider); - ref.invalidate(currentExercisePlanProvider); + ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); return null; } @@ -161,18 +177,7 @@ class ChatNotifier extends Notifier { void insertTaskCard() { if (state.messages.any((m) => m.type == MessageType.taskCard)) return; - state = state.copyWith( - messages: [ - ChatMessage( - id: 'task_card', - role: 'assistant', - content: '', - createdAt: DateTime.now(), - type: MessageType.taskCard, - ), - ...state.messages, - ], - ); + state = state.copyWith(messages: prependTodayHealthCard(state.messages)); } Future loadConversation(String convId) async { @@ -212,7 +217,7 @@ class ChatNotifier extends Notifier { }).toList(); state = ChatState( - messages: messages, + messages: prependTodayHealthCard(messages), conversationId: convId, activeAgent: ActiveAgent.default_, isViewingHistory: true, diff --git a/health_app/lib/providers/data_providers.dart b/health_app/lib/providers/data_providers.dart index 746a544..71d1ea8 100644 --- a/health_app/lib/providers/data_providers.dart +++ b/health_app/lib/providers/data_providers.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'auth_provider.dart'; +import 'data_refresh_providers.dart'; import '../services/health_service.dart'; import '../services/admin_service.dart'; import '../services/in_app_notification_service.dart'; @@ -63,6 +64,7 @@ final medicationListProvider = FutureProvider>>(( ref, ) async { ref.watch(userSessionIdentityProvider); + ref.watch(medicationDataRefreshSignalProvider); final service = ref.watch(medicationServiceProvider); return service.getList(); }); @@ -71,6 +73,7 @@ final exercisePlansProvider = FutureProvider>>(( ref, ) async { ref.watch(userSessionIdentityProvider); + ref.watch(exerciseDataRefreshSignalProvider); return ref.watch(exerciseServiceProvider).getPlans(); }); @@ -85,6 +88,7 @@ final dietRecordsProvider = FutureProvider>>(( ref, ) async { ref.watch(userSessionIdentityProvider); + ref.watch(dietDataRefreshSignalProvider); return ref.watch(dietServiceProvider).getRecords(); }); @@ -92,6 +96,7 @@ final medicationReminderProvider = FutureProvider>>(( ref, ) async { ref.watch(userSessionIdentityProvider); + ref.watch(medicationDataRefreshSignalProvider); final service = ref.watch(medicationServiceProvider); return service.getReminders(); }); @@ -109,6 +114,7 @@ final currentExercisePlanProvider = FutureProvider?>(( ref, ) async { ref.watch(userSessionIdentityProvider); + ref.watch(exerciseDataRefreshSignalProvider); final service = ref.watch(exerciseServiceProvider); return service.getCurrentPlan().timeout(const Duration(seconds: 8)); }); diff --git a/health_app/lib/providers/data_refresh_providers.dart b/health_app/lib/providers/data_refresh_providers.dart new file mode 100644 index 0000000..b34b90b --- /dev/null +++ b/health_app/lib/providers/data_refresh_providers.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class DataRefreshSignal extends Notifier { + @override + int build() => 0; + + void trigger() => state++; +} + +final exerciseDataRefreshSignalProvider = + NotifierProvider(DataRefreshSignal.new); + +final medicationDataRefreshSignalProvider = + NotifierProvider(DataRefreshSignal.new); + +final dietDataRefreshSignalProvider = NotifierProvider( + DataRefreshSignal.new, +); diff --git a/health_app/lib/widgets/keyboard_lift.dart b/health_app/lib/widgets/keyboard_lift.dart index 66c4810..abc9651 100644 --- a/health_app/lib/widgets/keyboard_lift.dart +++ b/health_app/lib/widgets/keyboard_lift.dart @@ -15,3 +15,20 @@ class KeyboardLift extends StatelessWidget { ); } } + +/// 只移动已有前景图层,不在键盘动画期间重新布局子组件。 +class KeyboardTranslate extends StatelessWidget { + final Widget child; + + const KeyboardTranslate({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.viewInsetsOf(context).bottom; + return Transform.translate( + offset: Offset(0, -bottom), + transformHitTests: true, + child: RepaintBoundary(child: child), + ); + } +} diff --git a/health_app/test/backoffice_refresh_test.dart b/health_app/test/backoffice_refresh_test.dart index 56f35b1..4a4e681 100644 --- a/health_app/test/backoffice_refresh_test.dart +++ b/health_app/test/backoffice_refresh_test.dart @@ -9,10 +9,20 @@ void main() { expect(container.read(adminDoctorsRefreshSignalProvider), 0); expect(container.read(doctorFollowupsRefreshSignalProvider), 0); + expect(container.read(doctorReportsRefreshSignalProvider), 0); + expect(container.read(doctorDashboardRefreshSignalProvider), 0); container.read(adminDoctorsRefreshSignalProvider.notifier).trigger(); expect(container.read(adminDoctorsRefreshSignalProvider), 1); expect(container.read(doctorFollowupsRefreshSignalProvider), 0); + expect(container.read(doctorReportsRefreshSignalProvider), 0); + expect(container.read(doctorDashboardRefreshSignalProvider), 0); + + container.read(doctorReportsRefreshSignalProvider.notifier).trigger(); + container.read(doctorDashboardRefreshSignalProvider.notifier).trigger(); + + expect(container.read(doctorReportsRefreshSignalProvider), 1); + expect(container.read(doctorDashboardRefreshSignalProvider), 1); }); } diff --git a/health_app/test/data_refresh_providers_test.dart b/health_app/test/data_refresh_providers_test.dart new file mode 100644 index 0000000..8b57119 --- /dev/null +++ b/health_app/test/data_refresh_providers_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/providers/data_refresh_providers.dart'; + +void main() { + test('patient data refresh signals advance independently', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(exerciseDataRefreshSignalProvider), 0); + expect(container.read(medicationDataRefreshSignalProvider), 0); + expect(container.read(dietDataRefreshSignalProvider), 0); + + container.read(exerciseDataRefreshSignalProvider.notifier).trigger(); + container.read(medicationDataRefreshSignalProvider.notifier).trigger(); + + expect(container.read(exerciseDataRefreshSignalProvider), 1); + expect(container.read(medicationDataRefreshSignalProvider), 1); + expect(container.read(dietDataRefreshSignalProvider), 0); + }); +} diff --git a/health_app/test/home_message_order_test.dart b/health_app/test/home_message_order_test.dart index da2916a..a7b7a69 100644 --- a/health_app/test/home_message_order_test.dart +++ b/health_app/test/home_message_order_test.dart @@ -46,6 +46,22 @@ void main() { expect(messageAtDisplayIndex(messages, 1).id, 'user'); }); + test('loaded history keeps one current health card at the top', () { + final history = [ + ChatMessage( + id: 'history-message', + role: 'user', + content: '历史消息', + createdAt: DateTime(2026, 1, 1), + ), + ]; + + final withCard = prependTodayHealthCard(history, now: DateTime(2026, 1, 2)); + + expect(withCard.first.type, MessageType.taskCard); + expect(withCard[1].id, 'history-message'); + }); + test('today health keeps one cached snapshot during background refresh', () { final providers = File( 'lib/providers/data_providers.dart', @@ -112,10 +128,7 @@ void main() { final source = File('lib/pages/home/home_page.dart').readAsStringSync(); expect(source, contains('drawerEnableOpenDragGesture: false')); - expect( - source, - contains('onHorizontalDragUpdate: _updateConversationDrawerDrag'), - ); + expect(source, contains('_updateConversationDrawerDrag,')); expect(source, isNot(contains('_drawerDragStartX'))); }); @@ -159,4 +172,16 @@ void main() { expect(input, isNot(contains('Border.all(color: AppColors.borderLight)'))); expect(source, isNot(contains('class _RoundToolButton'))); }); + + test('keyboard translates the conversation layer without state updates', () { + final home = File('lib/pages/home/home_page.dart').readAsStringSync(); + final keyboard = File('lib/widgets/keyboard_lift.dart').readAsStringSync(); + + expect(home, contains('KeyboardTranslate(')); + expect(home, contains('resizeToAvoidBottomInset: false')); + expect(keyboard, contains('class KeyboardTranslate')); + expect(keyboard, contains('Transform.translate(')); + expect(keyboard, contains('child: RepaintBoundary(child: child)')); + expect(keyboard, isNot(contains('setState('))); + }); } diff --git a/health_app/test/prelaunch_guardrails_test.dart b/health_app/test/prelaunch_guardrails_test.dart index 464a04c..4cfed70 100644 --- a/health_app/test/prelaunch_guardrails_test.dart +++ b/health_app/test/prelaunch_guardrails_test.dart @@ -7,6 +7,15 @@ import 'package:health_app/pages/remaining_pages.dart'; import 'package:health_app/services/health_ble_service.dart'; void main() { + test('manual entry keyboard does not relayout the trend dashboard', () { + final source = File('lib/pages/chart/trend_page.dart').readAsStringSync(); + + expect(source, contains('resizeToAvoidBottomInset: false')); + expect(source, contains('class _KeyboardInsetPadding')); + expect(source, contains('MediaQuery.viewInsetsOf(context).bottom')); + expect(source, contains('child: RepaintBoundary(')); + }); + test('unknown trend metric falls back to blood pressure', () { expect(normalizeTrendMetricType('unknown_metric'), 'blood_pressure'); expect(normalizeTrendMetricType(null), 'blood_pressure'); diff --git a/health_app/test/secondary_page_visuals_test.dart b/health_app/test/secondary_page_visuals_test.dart index 589d024..5ec249b 100644 --- a/health_app/test/secondary_page_visuals_test.dart +++ b/health_app/test/secondary_page_visuals_test.dart @@ -178,7 +178,8 @@ void main() { ); expect(row, contains('AppModuleVisuals.exercise.icon')); - expect(row, contains('color: AppColors.exerciseLight')); + expect(row, contains('color: _phaseColor(phase).withValues(alpha: 0.10)')); + expect(row, contains('color: _phaseColor(phase)')); expect(row, contains('left: 68')); expect(row, isNot(contains('width: 16'))); final modules = File('lib/core/app_module_visuals.dart').readAsStringSync();