feat: AI Agent 处理器扩展 + 服药打卡确认链路 + AI 气泡配色微调 + 数据刷新 providers 抽离 + 多端页面细节调整
This commit is contained in:
@@ -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<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,19 +42,19 @@ public interface IExerciseService
|
||||
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IExerciseRepository
|
||||
{
|
||||
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task AddAsync(ExercisePlan plan, CancellationToken ct);
|
||||
void Delete(ExercisePlan plan);
|
||||
|
||||
@@ -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<IReadOnlyList<ExercisePlanSummaryDto>> 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<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plans = await _exercises.ListAllAsync(userId, ct);
|
||||
return plans.Select(ToSummaryDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
|
||||
@@ -103,12 +109,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> 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);
|
||||
|
||||
@@ -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<AiMedicationDoseDto> Doses);
|
||||
|
||||
public sealed record MedicationReminderTask(
|
||||
Guid TaskId,
|
||||
Guid UserId,
|
||||
@@ -60,12 +75,10 @@ public interface IMedicationService
|
||||
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct);
|
||||
Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationRepository
|
||||
@@ -74,8 +87,6 @@ public interface IMedicationRepository
|
||||
Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
|
||||
|
||||
@@ -96,23 +96,6 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool?> 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<IReadOnlyList<MedicationReminderDto>> 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<bool?> 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<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct) =>
|
||||
ListAsync(userId, "active", ct);
|
||||
|
||||
public async Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
public async Task<IReadOnlyList<AiMedicationPlanDto>> 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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
|
||||
var 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<object> 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" }
|
||||
}
|
||||
}
|
||||
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__",
|
||||
};
|
||||
|
||||
public static Task<object> ExecuteManageArchive(
|
||||
IHealthArchiveService archives,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||
var 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);
|
||||
return normalized != "__invalid__";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) =>
|
||||
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
|
||||
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
|
||||
: null;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<object> 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
|
||||
{
|
||||
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
|
||||
{
|
||||
found = true,
|
||||
plan_id = plan.Id,
|
||||
plan.StartDate,
|
||||
plan.EndDate,
|
||||
phase = queryDate < plan.StartDate ? "upcoming" : queryDate > plan.EndDate ? "ended" : "active",
|
||||
plan.ReminderTime,
|
||||
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<object> 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<object> 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<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
|
||||
private static async Task<object> 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<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
||||
var success = await medications.ConfirmNowAsync(userId, medId, ct);
|
||||
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
|
||||
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<MedicationFrequency>(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<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily;
|
||||
return Enum.TryParse<MedicationFrequency>(frequencyStr, ignoreCase: true, out var frequency) ? frequency : MedicationFrequency.Daily;
|
||||
}
|
||||
|
||||
private static DateOnly ReadStartDate(JsonElement args)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
using Health.Application.Diets;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Application.Reports;
|
||||
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// 统一助手使用的患者本人业务数据只读工具。
|
||||
/// </summary>
|
||||
public static class PatientReadAgentHandler
|
||||
{
|
||||
public static readonly ToolDefinition QueryDietRecordsTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "query_diet_records",
|
||||
Description = "查询当前用户指定北京时间日期的饮食记录和总热量",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
date = new { type = "string", description = "北京时间日期 yyyy-MM-dd,默认今天" },
|
||||
meal_type = new { type = "string", @enum = new[] { "Breakfast", "Lunch", "Dinner", "Snack" }, description = "可选餐次" },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly ToolDefinition QueryFollowUpsTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "query_followups",
|
||||
Description = "查询当前用户的复查随访安排",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
scope = new { type = "string", @enum = new[] { "next", "upcoming", "completed", "date", "all" }, description = "查询时必填:下一次/未来安排/已完成/指定日期/全部" },
|
||||
date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" },
|
||||
limit = new { type = "integer", description = "最多返回条数,1到20" },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly ToolDefinition QueryReportsTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "query_reports",
|
||||
Description = "查询当前用户已经保存的检查报告状态、AI摘要和医生审核结论,不读取其他用户报告",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
limit = new { type = "integer", description = "最多返回条数,1到10,默认5" },
|
||||
status = new { type = "string", description = "可选报告状态" },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly ToolDefinition QueryNotificationsTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "query_notifications",
|
||||
Description = "查询当前用户的站内通知",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
scope = new { type = "string", @enum = new[] { "unread", "today", "recent" }, description = "默认 unread" },
|
||||
limit = new { type = "integer", description = "最多返回条数,1到20" },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static async Task<object> QueryDietAsync(
|
||||
IDietService diets,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (args.TryGetProperty("date", out var suppliedDate) && !DateOnly.TryParse(suppliedDate.GetString(), out _))
|
||||
return new { success = false, message = "查询饮食记录时提供了无效的 date" };
|
||||
var date = args.TryGetProperty("date", out var dateValue) && DateOnly.TryParse(dateValue.GetString(), out var parsedDate)
|
||||
? parsedDate
|
||||
: AiDateTime.BeijingToday;
|
||||
var mealType = args.TryGetProperty("meal_type", out var mealValue) ? mealValue.GetString() : null;
|
||||
var records = await diets.ListAsync(userId, date.ToString("yyyy-MM-dd"), mealType, ct);
|
||||
return new
|
||||
{
|
||||
query_date = date.ToString("yyyy-MM-dd"),
|
||||
count = records.Count,
|
||||
total_calories = records.Sum(record => record.TotalCalories ?? 0),
|
||||
records = records.Select(record => new
|
||||
{
|
||||
record.Id,
|
||||
record.MealType,
|
||||
record.TotalCalories,
|
||||
record.HealthScore,
|
||||
record.RecordedAt,
|
||||
record.FoodItems,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<object> QueryFollowUpsAsync(
|
||||
AppDbContext db,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||
? scopeValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (scope is not ("next" or "upcoming" or "completed" or "date" or "all"))
|
||||
return new
|
||||
{
|
||||
success = false,
|
||||
message = "查询复查安排时必须明确 scope:next、upcoming、completed、date 或 all",
|
||||
};
|
||||
var limit = ReadLimit(args, 10, 20);
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var query = db.FollowUps.AsNoTracking().Where(item => item.UserId == userId);
|
||||
|
||||
if (scope is "next" or "upcoming")
|
||||
query = query.Where(item => item.Status == FollowUpStatus.Upcoming && item.ScheduledAt >= nowUtc);
|
||||
else if (scope == "completed")
|
||||
query = query.Where(item => item.Status == FollowUpStatus.Completed);
|
||||
else if (scope == "date")
|
||||
{
|
||||
if (!args.TryGetProperty("date", out var dateValue) || !DateOnly.TryParse(dateValue.GetString(), out var date))
|
||||
return new { success = false, message = "按日期查询复查时必须提供有效的 date" };
|
||||
var (startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date);
|
||||
query = query.Where(item => item.ScheduledAt >= startUtc && item.ScheduledAt < endUtc);
|
||||
}
|
||||
|
||||
var take = scope == "next" ? 1 : limit;
|
||||
var orderedQuery = scope is "all" or "completed"
|
||||
? query.OrderByDescending(item => item.ScheduledAt)
|
||||
: query.OrderBy(item => item.ScheduledAt);
|
||||
var matchingCount = await query.CountAsync(ct);
|
||||
var items = await orderedQuery
|
||||
.Take(take)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.Title,
|
||||
item.DoctorName,
|
||||
item.Department,
|
||||
item.ScheduledAt,
|
||||
item.Notes,
|
||||
item.Status,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
scope,
|
||||
count = items.Count,
|
||||
matching_count = matchingCount,
|
||||
results_truncated = matchingCount > items.Count,
|
||||
followups = items.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.Title,
|
||||
item.DoctorName,
|
||||
item.Department,
|
||||
scheduled_at_beijing = AiDateTime.ToBeijingText(item.ScheduledAt),
|
||||
item.Notes,
|
||||
status = item.Status.ToString(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<object> QueryReportsAsync(
|
||||
IReportService reports,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var limit = ReadLimit(args, 5, 10);
|
||||
var status = args.TryGetProperty("status", out var statusValue) ? statusValue.GetString() : null;
|
||||
var records = await reports.GetReportsAsync(userId, ct);
|
||||
var filtered = records
|
||||
.Where(report => string.IsNullOrWhiteSpace(status) || report.Status.Equals(status, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(report => report.CreatedAt)
|
||||
.Take(limit)
|
||||
.Select(report => new
|
||||
{
|
||||
report.Id,
|
||||
report.Category,
|
||||
report.Status,
|
||||
report.AiStatus,
|
||||
report.ReviewStatus,
|
||||
report.Severity,
|
||||
ai_summary = Truncate(report.AiSummary, 1800),
|
||||
doctor_comment = Truncate(report.DoctorComment, 1000),
|
||||
doctor_recommendation = Truncate(report.DoctorRecommendation, 1000),
|
||||
report.DoctorName,
|
||||
reviewed_at_beijing = report.ReviewedAt.HasValue ? AiDateTime.ToBeijingText(report.ReviewedAt.Value) : null,
|
||||
created_at_beijing = AiDateTime.ToBeijingText(report.CreatedAt),
|
||||
})
|
||||
.ToList();
|
||||
return new { count = filtered.Count, reports = filtered };
|
||||
}
|
||||
|
||||
public static async Task<object> QueryNotificationsAsync(
|
||||
IInAppNotificationService notifications,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||
? scopeValue.GetString()?.ToLowerInvariant() ?? "unread"
|
||||
: "unread";
|
||||
if (scope is not ("unread" or "today" or "recent")) scope = "unread";
|
||||
var limit = ReadLimit(args, 10, 20);
|
||||
var records = scope == "unread"
|
||||
? await notifications.GetPendingAsync(userId, ct)
|
||||
: await notifications.GetHistoryAsync(userId, ct);
|
||||
var (todayStartUtc, todayEndUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday);
|
||||
var filtered = records
|
||||
.Where(item => scope != "today" ||
|
||||
(AiDateTime.ToUtc(item.CreatedAt) >= todayStartUtc && AiDateTime.ToUtc(item.CreatedAt) < todayEndUtc))
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(limit)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.Type,
|
||||
item.Title,
|
||||
item.Message,
|
||||
item.Severity,
|
||||
item.IsRead,
|
||||
created_at_beijing = AiDateTime.ToBeijingText(item.CreatedAt),
|
||||
})
|
||||
.ToList();
|
||||
return new { scope, count = filtered.Count, notifications = filtered };
|
||||
}
|
||||
|
||||
private static int ReadLimit(JsonElement args, int defaultValue, int max) =>
|
||||
args.TryGetProperty("limit", out var value) && value.TryGetInt32(out var parsed)
|
||||
? Math.Clamp(parsed, 1, max)
|
||||
: defaultValue;
|
||||
|
||||
private static string? Truncate(string? value, int maxLength) =>
|
||||
string.IsNullOrWhiteSpace(value) || value.Length <= maxLength ? value : value[..maxLength] + "…";
|
||||
}
|
||||
@@ -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<object> 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<object>(new { success = false, message = $"未知工具: {toolName}" }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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”必须生成待确认命令
|
||||
|
||||
@@ -19,16 +19,16 @@ public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<ExercisePlan>> 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<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) =>
|
||||
_db.ExercisePlans.Include(p => p.Items)
|
||||
.FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct);
|
||||
|
||||
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) =>
|
||||
_db.ExercisePlans.Include(p => p.Items)
|
||||
.Where(p => p.UserId == userId)
|
||||
.OrderByDescending(p => p.StartDate)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
public Task<ExercisePlanItem?> 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);
|
||||
|
||||
@@ -30,15 +30,6 @@ public sealed class EfMedicationRepository(AppDbContext db) : IMedicationReposit
|
||||
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
|
||||
_db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct);
|
||||
|
||||
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
|
||||
_db.Medications.AnyAsync(m => m.Id == medicationId && m.UserId == userId, ct);
|
||||
|
||||
public Task<MedicationLog?> 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<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
|
||||
_db.MedicationLogs.AnyAsync(l =>
|
||||
l.MedicationId == medicationId && l.UserId == userId
|
||||
|
||||
@@ -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<object> 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<string, object?>
|
||||
{
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||
Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []);
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>([]);
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||
Plan != null && Plan.UserId == userId ? [Plan] : []);
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||
Plan != null && Plan.UserId == userId ? [Plan] : []);
|
||||
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan);
|
||||
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) => Task.FromResult(Plan);
|
||||
public Task<ExercisePlanItem?> 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; }
|
||||
|
||||
@@ -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<MedicationLog>? logs = null) : IMedicationRepository
|
||||
{
|
||||
public bool Saved { get; private set; }
|
||||
public List<MedicationLog> AddedLogs { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<IReadOnlyList<MedicationLog>>([]);
|
||||
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(logs ?? (IReadOnlyList<MedicationLog>)[]);
|
||||
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult<Medication?>(medication.UserId == userId && medication.Id == medicationId ? medication : null);
|
||||
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId);
|
||||
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false);
|
||||
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||
public Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,13 +292,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
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<TrendPage> {
|
||||
@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<TrendPage> {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<DietCapturePage> {
|
||||
Future<void> _saveDietRecord() async {
|
||||
try {
|
||||
await ref.read(dietProvider.notifier).saveRecord();
|
||||
ref.read(dietDataRefreshSignalProvider.notifier).trigger();
|
||||
if (mounted) popRoute(ref);
|
||||
} on DietFoodValidationException catch (e) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -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<Map<String, dynamic>?>((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<String, dynamic>?;
|
||||
|
||||
@@ -274,6 +274,7 @@ class _DoctorFollowUpEditPageState
|
||||
}
|
||||
if (mounted) {
|
||||
ref.read(doctorFollowupsRefreshSignalProvider.notifier).trigger();
|
||||
ref.read(doctorDashboardRefreshSignalProvider.notifier).trigger();
|
||||
_snack(isEdit ? '修改成功' : '创建成功', ok: true);
|
||||
popRoute(ref);
|
||||
}
|
||||
|
||||
@@ -211,6 +211,12 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
|
||||
.markDone(
|
||||
f['id']?.toString() ?? '',
|
||||
);
|
||||
ref
|
||||
.read(
|
||||
doctorDashboardRefreshSignalProvider
|
||||
.notifier,
|
||||
)
|
||||
.trigger();
|
||||
},
|
||||
child: const Text(
|
||||
'完成',
|
||||
@@ -232,6 +238,12 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
|
||||
.remove(
|
||||
f['id']?.toString() ?? '',
|
||||
);
|
||||
ref
|
||||
.read(
|
||||
doctorDashboardRefreshSignalProvider
|
||||
.notifier,
|
||||
)
|
||||
.trigger();
|
||||
},
|
||||
child: const Text(
|
||||
'删除',
|
||||
|
||||
@@ -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<DoctorProfileEditPage> {
|
||||
},
|
||||
);
|
||||
ref.invalidate(_docProfileProvider);
|
||||
ref.read(doctorDashboardRefreshSignalProvider.notifier).trigger();
|
||||
if (mounted) {
|
||||
AppToast.show(context, '保存成功', type: AppToastType.success);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<List<Map<String, dynamic>>>((
|
||||
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<Map<String, dynamic>>() ?? [];
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -169,19 +169,31 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(user),
|
||||
Expanded(
|
||||
child: ClipRect(
|
||||
child: KeyboardTranslate(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: _startConversationDrawerDrag,
|
||||
onHorizontalDragUpdate: _updateConversationDrawerDrag,
|
||||
onHorizontalDragUpdate:
|
||||
_updateConversationDrawerDrag,
|
||||
onHorizontalDragEnd: _endConversationDrawerDrag,
|
||||
onHorizontalDragCancel: () => _conversationDragDistance = 0,
|
||||
onHorizontalDragCancel: () =>
|
||||
_conversationDragDistance = 0,
|
||||
child: RepaintBoundary(
|
||||
child: _HomeMessages(scrollCtrl: _scrollCtrl),
|
||||
),
|
||||
),
|
||||
),
|
||||
KeyboardLift(child: _buildBottomBar(context)),
|
||||
_buildBottomBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -420,6 +420,8 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
) {
|
||||
final meta = msg.metadata ?? <String, dynamic>{};
|
||||
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),
|
||||
|
||||
@@ -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<MedicationCheckInPage> {
|
||||
throw const ApiException('该次服药已经打卡');
|
||||
}
|
||||
}
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
ref.invalidate(medicationListProvider);
|
||||
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
|
||||
_load();
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
|
||||
@@ -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<MedicationEditPage> {
|
||||
} 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<MedicationEditPage> {
|
||||
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);
|
||||
|
||||
@@ -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<MedicationListPage> {
|
||||
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) {
|
||||
|
||||
@@ -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<DietRecordListPage> {
|
||||
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<DietRecordListPage> {
|
||||
|
||||
@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<DietRecordListPage> {
|
||||
});
|
||||
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<ExercisePlanPage> {
|
||||
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<ExercisePlanPage> {
|
||||
|
||||
Future<void> _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<HealthCalendarPage> {
|
||||
_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,
|
||||
|
||||
@@ -12,3 +12,9 @@ final adminDoctorsRefreshSignalProvider =
|
||||
|
||||
final doctorFollowupsRefreshSignalProvider =
|
||||
NotifierProvider<BackofficeRefreshSignal, int>(BackofficeRefreshSignal.new);
|
||||
|
||||
final doctorReportsRefreshSignalProvider =
|
||||
NotifierProvider<BackofficeRefreshSignal, int>(BackofficeRefreshSignal.new);
|
||||
|
||||
final doctorDashboardRefreshSignalProvider =
|
||||
NotifierProvider<BackofficeRefreshSignal, int>(BackofficeRefreshSignal.new);
|
||||
|
||||
@@ -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<ChatMessage> prependTodayHealthCard(
|
||||
List<ChatMessage> 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<ChatState> {
|
||||
|
||||
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<ChatState> {
|
||||
|
||||
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<String?> loadConversation(String convId) async {
|
||||
@@ -212,7 +217,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
}).toList();
|
||||
|
||||
state = ChatState(
|
||||
messages: messages,
|
||||
messages: prependTodayHealthCard(messages),
|
||||
conversationId: convId,
|
||||
activeAgent: ActiveAgent.default_,
|
||||
isViewingHistory: true,
|
||||
|
||||
@@ -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<List<Map<String, dynamic>>>((
|
||||
ref,
|
||||
) async {
|
||||
ref.watch(userSessionIdentityProvider);
|
||||
ref.watch(medicationDataRefreshSignalProvider);
|
||||
final service = ref.watch(medicationServiceProvider);
|
||||
return service.getList();
|
||||
});
|
||||
@@ -71,6 +73,7 @@ final exercisePlansProvider = FutureProvider<List<Map<String, dynamic>>>((
|
||||
ref,
|
||||
) async {
|
||||
ref.watch(userSessionIdentityProvider);
|
||||
ref.watch(exerciseDataRefreshSignalProvider);
|
||||
return ref.watch(exerciseServiceProvider).getPlans();
|
||||
});
|
||||
|
||||
@@ -85,6 +88,7 @@ final dietRecordsProvider = FutureProvider<List<Map<String, dynamic>>>((
|
||||
ref,
|
||||
) async {
|
||||
ref.watch(userSessionIdentityProvider);
|
||||
ref.watch(dietDataRefreshSignalProvider);
|
||||
return ref.watch(dietServiceProvider).getRecords();
|
||||
});
|
||||
|
||||
@@ -92,6 +96,7 @@ final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((
|
||||
ref,
|
||||
) async {
|
||||
ref.watch(userSessionIdentityProvider);
|
||||
ref.watch(medicationDataRefreshSignalProvider);
|
||||
final service = ref.watch(medicationServiceProvider);
|
||||
return service.getReminders();
|
||||
});
|
||||
@@ -109,6 +114,7 @@ final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((
|
||||
ref,
|
||||
) async {
|
||||
ref.watch(userSessionIdentityProvider);
|
||||
ref.watch(exerciseDataRefreshSignalProvider);
|
||||
final service = ref.watch(exerciseServiceProvider);
|
||||
return service.getCurrentPlan().timeout(const Duration(seconds: 8));
|
||||
});
|
||||
|
||||
18
health_app/lib/providers/data_refresh_providers.dart
Normal file
18
health_app/lib/providers/data_refresh_providers.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class DataRefreshSignal extends Notifier<int> {
|
||||
@override
|
||||
int build() => 0;
|
||||
|
||||
void trigger() => state++;
|
||||
}
|
||||
|
||||
final exerciseDataRefreshSignalProvider =
|
||||
NotifierProvider<DataRefreshSignal, int>(DataRefreshSignal.new);
|
||||
|
||||
final medicationDataRefreshSignalProvider =
|
||||
NotifierProvider<DataRefreshSignal, int>(DataRefreshSignal.new);
|
||||
|
||||
final dietDataRefreshSignalProvider = NotifierProvider<DataRefreshSignal, int>(
|
||||
DataRefreshSignal.new,
|
||||
);
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
21
health_app/test/data_refresh_providers_test.dart
Normal file
21
health_app/test/data_refresh_providers_test.dart
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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(')));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user