feat: AI Agent 处理器扩展 + 服药打卡确认链路 + AI 气泡配色微调 + 数据刷新 providers 抽离 + 多端页面细节调整
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user