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

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

View File

@@ -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; }

View File

@@ -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; }

View File

@@ -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);
}
}