feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新

后端:
- 新增 ai_intent_router 意图路由
- 新增 AiEntryDraft 草稿存储 + EF 迁移
- 新增 Prompts 模块化(global/modules/rag/router)
- AI Agent handlers 扩展

前端:
- 新增 AI 同意门 (ai_consent_gate/provider/details_page)
- HealthMetricVisuals 视觉统一
- 设备扫描/管理页面优化
- agent 插图替换 + 趋势指标图标

配置:
- DeepSeek 模型改 deepseek-v4-flash
- .gitignore 加 artifacts/audit
- build.gradle.kts 签名配置调整
This commit is contained in:
MingNian
2026-07-28 15:03:38 +08:00
parent 25a4078688
commit b4bc15b9b9
88 changed files with 5527 additions and 1240 deletions

View File

@@ -97,6 +97,66 @@ public sealed class ApplicationServiceTests
Assert.Equal(1, repository.SaveCount);
}
[Fact]
public async Task HealthEntryBatch_ExecutesAllMetricsWithOneSave()
{
var repository = new CapturingHealthRecordRepository();
var service = new HealthRecordService(repository);
using var arguments = JsonDocument.Parse(
"""
{
"metrics": [
{ "type": "blood_pressure", "systolic": 113, "diastolic": 86 },
{ "type": "heart_rate", "heart_rate": 80 },
{ "type": "glucose", "glucose": 5.6 }
]
}
""");
var result = await HealthDataAgentHandler.Execute(
"record_health_data_batch",
arguments.RootElement,
Guid.NewGuid(),
service,
CancellationToken.None);
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.True(resultJson.RootElement.GetProperty("success").GetBoolean());
Assert.Equal(3, resultJson.RootElement.GetProperty("record_ids").GetArrayLength());
Assert.Equal(3, resultJson.RootElement.GetProperty("items").GetArrayLength());
Assert.Equal(3, repository.Added.Count);
Assert.Equal(1, repository.SaveCount);
}
[Fact]
public async Task HealthEntryBatch_IncompleteMetricBlocksWholeBatch()
{
var repository = new CapturingHealthRecordRepository();
var service = new HealthRecordService(repository);
using var arguments = JsonDocument.Parse(
"""
{
"metrics": [
{ "type": "blood_pressure", "systolic": 113 },
{ "type": "heart_rate", "heart_rate": 80 }
]
}
""");
var result = await HealthDataAgentHandler.Execute(
"record_health_data_batch",
arguments.RootElement,
Guid.NewGuid(),
service,
CancellationToken.None);
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.False(resultJson.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("舒张压", resultJson.RootElement.GetProperty("message").GetString());
Assert.Empty(repository.Added);
Assert.Equal(0, repository.SaveCount);
}
[Fact]
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
{
@@ -308,9 +368,66 @@ public sealed class ApplicationServiceTests
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
Assert.Contains("散步", upcomingJson.RootElement.GetProperty("authoritative_answer").GetString());
Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32());
}
[Fact]
public async Task AiExerciseQuery_AuthoritativeAnswerPreservesStoredExerciseType()
{
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,
EndDate = today.AddDays(6),
ReminderTime = new TimeOnly(15, 0),
};
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(),
ScheduledDate = today,
ExerciseType = "打篮球",
DurationMinutes = 30,
});
repository.SetPlan(plan);
var service = new ExerciseService(repository);
using var arguments = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}""");
var result = await ExerciseAgentHandler.Execute(
"manage_exercise", arguments.RootElement, userId, service, CancellationToken.None);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
var answer = json.RootElement.GetProperty("authoritative_answer").GetString();
Assert.Contains("打篮球", answer);
Assert.DoesNotContain("打毽子", answer);
Assert.Contains("15:00", answer);
}
[Fact]
public void MedicationEntry_AcceptsPillCountAsDosage()
{
using var arguments = JsonDocument.Parse(
"""
{
"action": "create",
"name": "维生素D",
"dosage": "1粒",
"frequency": "Daily",
"time_of_day": ["12:00"],
"start_date": "2026-07-27",
"duration_days": 3
}
""");
var validationError = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
Assert.Null(validationError);
}
[Fact]
public async Task AiExerciseQuery_RequiresExplicitScopeInsteadOfAssumingToday()
{

View File

@@ -1,47 +1,69 @@
using Health.Infrastructure.AI;
using Health.Domain.Enums;
namespace Health.Tests;
public sealed class PromptManagerTests
{
[Fact]
public void UnifiedPrompt_DistinguishesQuestionsFromEntryRequests()
{
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
private readonly PromptManager _prompts = new();
Assert.Contains("先判断用户是在咨询数值,还是希望记录数据", prompt);
Assert.Contains("血氧98%是不是太高了", prompt);
Assert.Contains("先回答问题,不调用 record_health_data", prompt);
Assert.Contains("帮我记录血压116/89", prompt);
[Fact]
public void RouterPrompt_DistinguishesEntryConsultationAndDraftContinuation()
{
var prompt = _prompts.GetIntentRouterPrompt();
Assert.Contains("只负责识别意图,不回答用户问题", prompt);
Assert.Contains("帮我记录血压 116/89", prompt);
Assert.Contains("血压 116/89 正常吗", prompt);
Assert.Contains("同时包含 health_entry 和 medical_consultation", prompt);
Assert.Contains("存在草稿不代表所有后续消息都必须继续草稿", prompt);
}
[Fact]
public void UnifiedPrompt_RequiresFreshToolsForPersonalStatusAndKeepsArchiveReadOnly()
public void HealthEntryPrompt_RequiresWholeBatchDraftAndOneConfirmationCard()
{
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
var prompt = _prompts.ComposeForIntents(["health_entry"]);
Assert.Contains("必须先调用对应只读工具", prompt);
Assert.Contains("当前工具结果是最高优先级", prompt);
Assert.Contains("聊天中不修改健康档案", prompt);
Assert.DoesNotContain("健康档案查询/修改", prompt);
Assert.Contains("113/86、113-86、113—86", prompt);
Assert.Contains("多指标属于同一录入批次", prompt);
Assert.Contains("record_health_data_batch", prompt);
Assert.Contains("每个录入批次只调用一次", prompt);
Assert.Contains("先根据后端结果追问缺失或错误部分", prompt);
Assert.Contains("全部补齐后再生成整批确认卡", prompt);
Assert.Contains("只有后端明确提供的待补充草稿", prompt);
Assert.DoesNotContain("医学知识库使用规则", prompt);
}
[Fact]
public void UnifiedPrompt_UsesACommonTemporalIntentModelForPlanQueries()
public void EntryPrompts_DoNotInventRequiredPlanFields()
{
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
var medication = _prompts.ComposeForIntents(["medication_entry"]);
var exercise = _prompts.ComposeForIntents(["exercise_entry"]);
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);
Assert.Contains("药品名称、每次剂量、频率、具体服药时间、开始日期", medication);
Assert.Contains("不自行新增药品、猜测剂量", medication);
Assert.Contains("运动项目、每天运动时长、持续天数、开始日期和具体提醒时间", exercise);
Assert.Contains("当前产品不自动补默认", exercise);
}
[Fact]
public void RagRules_AreOnlyComposedForMedicalKnowledgeModules()
{
var consultation = _prompts.ComposeForIntents(["medical_consultation"]);
var query = _prompts.ComposeForIntents(["personal_query"]);
var chat = _prompts.ComposeForIntents(["general_chat"]);
Assert.Contains("医学知识库使用规则", consultation);
Assert.DoesNotContain("医学知识库使用规则", query);
Assert.DoesNotContain("医学知识库使用规则", chat);
}
[Fact]
public void MixedIntentPrompt_ComposesBothEntryAndConsultationRules()
{
var prompt = _prompts.ComposeForIntents(["health_entry", "medical_consultation"]);
Assert.Contains("健康指标录入模块", prompt);
Assert.Contains("医疗咨询与健康建议模块", prompt);
Assert.Contains("医学知识库使用规则", prompt);
}
}