feat: AI 提示词模块化 + AI 同意门控 + AI 草稿存储 + 意图路由 + 医疗引用知识库 + 多页面 UI 优化
- AI 提示词拆分为 markdown 模块(Prompts/global + modules + rag + router) - 新增 AI 同意门控(用户需同意后才能使用 AI 功能) - 新增 AI 录入草稿存储(AiEntryDraftContracts/EfAiEntryDraftStore/AiEntryDraftRecord) - 新增 AI 意图路由(ai_intent_router) - 新增医疗引用知识库(MedicalCitationKnowledge) - 重构 prompt_manager 和 ai_chat_endpoints - 优化聊天/趋势/档案/抽屉/医生端等多个页面 UI - 新增 agent 插画和趋势指标图标资源 - 删除 HANDOFF-2026-07-17.md
This commit is contained in:
24
backend/src/Health.Application/AI/AiEntryDraftContracts.cs
Normal file
24
backend/src/Health.Application/AI/AiEntryDraftContracts.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed record AiEntryDraft(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
Guid ConversationId,
|
||||
string EntryType,
|
||||
string Payload,
|
||||
string MissingFields,
|
||||
DateTime ExpiresAt);
|
||||
|
||||
public interface IAiEntryDraftStore
|
||||
{
|
||||
Task<AiEntryDraft?> GetActiveAsync(Guid userId, Guid conversationId, string entryType, CancellationToken ct);
|
||||
Task<AiEntryDraft> UpsertAsync(
|
||||
Guid userId,
|
||||
Guid conversationId,
|
||||
string entryType,
|
||||
string payload,
|
||||
string missingFields,
|
||||
TimeSpan lifetime,
|
||||
CancellationToken ct);
|
||||
Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public static class DietCommentaryPolicy
|
||||
每条12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。
|
||||
优先围绕控盐控油、蔬菜和蛋白质搭配及份量控制描述客观事实(如“本餐盐分约5克”“蔬菜偏少”)。
|
||||
不作诊断,不提供治疗、处方、停药或调药结论,不给饮食建议,不基于疾病推断。语言简洁、专业、面向普通用户。
|
||||
如果输出了营养事实或饮食相关健康信息,末尾必须附上“参考来源”,使用固定医学来源目录中的 S-DIET-01 或 S-DIET-02。
|
||||
""";
|
||||
|
||||
public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) =>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace Health.Application.AI;
|
||||
|
||||
/// <summary>
|
||||
/// iOS 审核版内置医学来源目录。
|
||||
/// 这是固定提示词资料,不依赖在线 RAG 检索。
|
||||
/// </summary>
|
||||
public static class MedicalCitationKnowledge
|
||||
{
|
||||
public const string Prompt = """
|
||||
固定医学参考来源目录(仅可引用下列来源,不得编造 PMID、DOI 或 URL):
|
||||
|
||||
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
|
||||
适用:血压记录、血压趋势、单次血压不能单独诊断、复测和就医提醒。
|
||||
https://pubmed.ncbi.nlm.nih.gov/39210715/
|
||||
|
||||
[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes—2025
|
||||
适用:血糖记录、空腹或餐后状态、低血糖风险和血糖结果说明。
|
||||
https://pubmed.ncbi.nlm.nih.gov/39651981/
|
||||
|
||||
[S-HR-01] All About Heart Rate, American Heart Association
|
||||
适用:静息心率的客观说明和心率记录趋势。
|
||||
https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse
|
||||
|
||||
[S-OXY-01] Pulse Oximeter Basics, U.S. Food and Drug Administration
|
||||
适用:血氧读数局限、复测、症状和血氧记录说明。
|
||||
https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
|
||||
|
||||
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
|
||||
适用:一般饮食模式、食物营养特征、饮食图片估算局限。
|
||||
https://pubmed.ncbi.nlm.nih.gov/37128940/
|
||||
|
||||
[S-DIET-02] Diet and nutrition in cardiovascular disease prevention
|
||||
适用:一般心血管健康饮食信息,不用于个体化疾病饮食处方。
|
||||
https://pubmed.ncbi.nlm.nih.gov/40504596/
|
||||
|
||||
[S-EX-01] Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
|
||||
适用:一般运动计划记录和运动安全提醒,不用于疾病运动处方。
|
||||
https://pubmed.ncbi.nlm.nih.gov/38059362/
|
||||
|
||||
[S-EX-02] Core Components of Cardiac Rehabilitation Programs: 2024 Update
|
||||
适用:心脏康复相关安全边界,只能建议咨询专业人员,不能生成康复处方。
|
||||
https://pubmed.ncbi.nlm.nih.gov/39315436/
|
||||
|
||||
[S-MED-01] Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
|
||||
适用:用药计划记录、服药提醒和建议咨询医生或药师。
|
||||
https://pubmed.ncbi.nlm.nih.gov/39480983/
|
||||
|
||||
[S-REPORT-01] Interpreting Normal Values and Reference Ranges for Laboratory Tests
|
||||
适用:报告指标、参考范围和异常标记的客观说明。
|
||||
https://pubmed.ncbi.nlm.nih.gov/40268322/
|
||||
|
||||
[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
|
||||
适用:不同实验室参考范围差异和优先使用原报告参考范围。
|
||||
https://pubmed.ncbi.nlm.nih.gov/29729142/
|
||||
|
||||
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
|
||||
适用:危险症状安全提醒、及时联系当地急救服务和不要等待 AI 判断。
|
||||
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
|
||||
|
||||
来源使用边界:
|
||||
- 血压:单次记录不能单独诊断高血压;应结合多次记录、测量条件、症状和医生评估;不得建议自行加药、减药或停药。
|
||||
- 血糖:必须关注空腹/餐后等测量时段和既往用药;不能把一个统一目标套用给所有人;不得据此诊断糖尿病或指导自行调药。
|
||||
- 心率:静息心率的客观范围不能替代诊断;应结合是否安静、是否有不适和变化趋势说明。
|
||||
- 血氧:读数是估计值,可能受手部温度、活动、循环、皮肤因素和设备影响;应结合症状和复测结果,不得仅凭单个数值诊断或指导吸氧/调药。
|
||||
- 饮食:图片不能精确确定重量、份量、烹调油和调味料;盐、糖、脂肪和热量只能作一般估算;不得根据疾病档案生成个体化饮食处方。
|
||||
- 运动:只能提供一般运动计划记录和安全提醒;不得针对心血管疾病制定运动处方;有胸痛、晕厥或明显呼吸困难时应先就医评估。
|
||||
- 用药:只能记录计划、查询任务和提醒;开始、停止、更换或调整药物应由医生或药师决定。
|
||||
- 报告:优先使用报告本身提供的参考范围;标记高/低不等于诊断;不同实验室参考范围不能直接互换;应由医生结合症状和病史解释。
|
||||
- 危险症状:胸部不适、呼吸困难、单侧无力、语言障碍、意识异常等应立即联系当地急救服务,不要等待 AI 继续判断。
|
||||
""";
|
||||
|
||||
public const string FixedDietCitation = """
|
||||
参考来源:
|
||||
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
|
||||
https://pubmed.ncbi.nlm.nih.gov/37128940/
|
||||
""";
|
||||
|
||||
public const string FixedReportCitation = """
|
||||
参考来源:
|
||||
[S-REPORT-01] Interpreting Normal Values and Reference Ranges for Laboratory Tests
|
||||
https://pubmed.ncbi.nlm.nih.gov/40268322/
|
||||
[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
|
||||
https://pubmed.ncbi.nlm.nih.gov/29729142/
|
||||
""";
|
||||
|
||||
public const string EmergencyCitation = """
|
||||
参考来源:
|
||||
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
|
||||
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
|
||||
""";
|
||||
}
|
||||
@@ -188,6 +188,16 @@ public sealed class HealthRecordService(
|
||||
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "")
|
||||
};
|
||||
|
||||
var citation = record.MetricType switch
|
||||
{
|
||||
HealthMetricType.BloodPressure => "参考来源:\n[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension\nhttps://pubmed.ncbi.nlm.nih.gov/39210715/",
|
||||
HealthMetricType.Glucose => "参考来源:\n[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes—2025\nhttps://pubmed.ncbi.nlm.nih.gov/39651981/",
|
||||
HealthMetricType.HeartRate => "参考来源:\n[S-HR-01] All About Heart Rate, American Heart Association\nhttps://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse",
|
||||
HealthMetricType.SpO2 => "参考来源:\n[S-OXY-01] Pulse Oximeter Basics, U.S. Food and Drug Administration\nhttps://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics",
|
||||
_ => string.Empty,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(citation)) message = $"{message}\n\n{citation}";
|
||||
|
||||
var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
|
||||
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
|
||||
await _notifications.EnqueueAsync(
|
||||
|
||||
@@ -14,7 +14,8 @@ public sealed record UserProfileDto(
|
||||
public sealed record UserProfileUpdateRequest(
|
||||
string? Name,
|
||||
string? Gender,
|
||||
DateOnly? BirthDate);
|
||||
DateOnly? BirthDate,
|
||||
string? AvatarUrl);
|
||||
|
||||
public sealed record AccountFileReferences(
|
||||
IReadOnlyList<string> FileUrls,
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class UserService(
|
||||
if (request.Name != null) user.Name = request.Name;
|
||||
if (request.Gender != null) user.Gender = request.Gender;
|
||||
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
|
||||
if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _users.SaveChangesAsync(ct);
|
||||
return true;
|
||||
|
||||
@@ -8,6 +8,28 @@ namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
/// </summary>
|
||||
public static class CommonAgentHandler
|
||||
{
|
||||
public static readonly ToolDefinition SearchMedicalKnowledgeTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "search_medical_knowledge",
|
||||
Description = "按需检索医学知识库。仅用于症状分析、健康建议、疾病/药品/检查指标解释等需要医学资料支撑的回答;健康数据录入、用药或运动计划创建/查询/打卡、普通寒暄不得调用。",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
query = new
|
||||
{
|
||||
type = "string",
|
||||
description = "需要检索的医学问题,保留关键症状、疾病、药品或指标名称",
|
||||
},
|
||||
},
|
||||
required = new[] { "query" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static readonly ToolDefinition QueryHealthRecordsTool = new()
|
||||
{
|
||||
Function = new()
|
||||
|
||||
@@ -32,6 +32,31 @@ public static class ExerciseAgentHandler
|
||||
|
||||
public static List<ToolDefinition> Tools => [ManageExerciseTool];
|
||||
|
||||
public static string? ValidateWriteArguments(JsonElement args)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var actionValue)
|
||||
? actionValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (action != "create") return null;
|
||||
|
||||
if (!args.TryGetProperty("exercise_type", out var typeValue) ||
|
||||
string.IsNullOrWhiteSpace(typeValue.GetString()))
|
||||
return "创建运动计划前需要确认运动项目";
|
||||
if (!args.TryGetProperty("duration_minutes", out var minutesValue) ||
|
||||
!minutesValue.TryGetInt32(out var minutes) || minutes is <= 0 or > 300)
|
||||
return "创建运动计划前需要确认合理的单次运动时长";
|
||||
if (!args.TryGetProperty("duration_days", out var daysValue) ||
|
||||
!daysValue.TryGetInt32(out var days) || days is <= 0 or > 366)
|
||||
return "创建运动计划前需要确认计划持续天数";
|
||||
if (!args.TryGetProperty("start_date", out var startValue) ||
|
||||
!DateOnly.TryParse(startValue.GetString(), out _))
|
||||
return "创建运动计划前需要确认开始日期";
|
||||
if (!args.TryGetProperty("reminder_time", out var reminderValue) ||
|
||||
!TimeOnly.TryParse(reminderValue.GetString(), out _))
|
||||
return "创建运动计划前需要确认具体提醒时间";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
@@ -47,6 +72,10 @@ public static class ExerciseAgentHandler
|
||||
: null;
|
||||
if (action == "create")
|
||||
{
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
|
||||
? parsedStart
|
||||
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
@@ -134,6 +163,33 @@ public static class ExerciseAgentHandler
|
||||
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();
|
||||
string authoritativeAnswer;
|
||||
if (result.Count == 0)
|
||||
{
|
||||
authoritativeAnswer = isDateScope
|
||||
? $"没有查到 {queryDate:yyyy-MM-dd} 的运动安排。"
|
||||
: "当前没有查到符合条件的运动计划。";
|
||||
}
|
||||
else if (isDateScope)
|
||||
{
|
||||
var taskLines = scheduledTasks.Select(item =>
|
||||
$"- {item.ExerciseType}:{item.DurationMinutes} 分钟,{(item.IsCompleted ? "已完成" : "未完成")}");
|
||||
authoritativeAnswer =
|
||||
$"{queryDate:yyyy-MM-dd} 的运动安排:\n{string.Join('\n', taskLines)}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var planLines = result.Select(plan =>
|
||||
{
|
||||
var representative = plan.items.FirstOrDefault(item => !item.IsRestDay);
|
||||
var exerciseType = representative?.ExerciseType ?? "运动";
|
||||
var durationMinutes = representative?.DurationMinutes ?? 0;
|
||||
return $"- {exerciseType}:{plan.StartDate:yyyy-MM-dd} 至 {plan.EndDate:yyyy-MM-dd},"
|
||||
+ $"每天 {durationMinutes} 分钟,提醒时间 {plan.ReminderTime:HH:mm}";
|
||||
});
|
||||
authoritativeAnswer =
|
||||
$"查到 {result.Count} 个符合条件的运动计划:\n{string.Join('\n', planLines)}";
|
||||
}
|
||||
return new
|
||||
{
|
||||
found = result.Count > 0,
|
||||
@@ -147,6 +203,7 @@ public static class ExerciseAgentHandler
|
||||
next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"),
|
||||
days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber,
|
||||
plans = result,
|
||||
authoritative_answer = authoritativeAnswer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,29 +7,154 @@ namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
/// </summary>
|
||||
public static class HealthDataAgentHandler
|
||||
{
|
||||
private static readonly string[] MetricTypes = ["blood_pressure", "heart_rate", "glucose", "spo2", "weight"];
|
||||
|
||||
public static readonly ToolDefinition RecordHealthDataTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "record_health_data",
|
||||
Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
|
||||
Description = "记录健康数据(血压/心率/血糖/血氧/体重)。用户说“脉搏”时按“心率”处理,type 使用 heart_rate,数值写入 heart_rate。",
|
||||
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" } }
|
||||
}
|
||||
};
|
||||
|
||||
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||
public static readonly ToolDefinition RecordHealthDataBatchTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "record_health_data_batch",
|
||||
Description = "提交当前这一轮要录入的全部健康指标。一次只调用一次;即使只有一个指标也放入 metrics。血压可先只提供已知的一侧,脉搏按心率 heart_rate 处理。后端会整批校验、追问或生成一张确认卡。",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
metrics = new
|
||||
{
|
||||
type = "array",
|
||||
minItems = 1,
|
||||
description = "当前录入批次的全部指标,不得遗漏;不要带入普通历史对话或上一张已生成卡片的数据",
|
||||
items = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
type = new { type = "string", @enum = MetricTypes },
|
||||
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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
required = new[] { "metrics" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static async Task<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords)
|
||||
public static List<ToolDefinition> Tools => [RecordHealthDataBatchTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||
|
||||
public static string? ValidateWriteArguments(JsonElement args)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
|
||||
return type switch
|
||||
{
|
||||
"blood_pressure" => ValidateBloodPressure(args),
|
||||
"heart_rate" => ValidateValue(args, "heart_rate", "心率", 1m, 250m),
|
||||
"glucose" => ValidateValue(args, "glucose", "血糖", 0.1m, 50m),
|
||||
"spo2" => ValidateValue(args, "spo2", "血氧", 1m, 100m),
|
||||
"weight" => ValidateValue(args, "weight", "体重", 1m, 500m),
|
||||
_ => "缺少有效的健康指标类型",
|
||||
};
|
||||
}
|
||||
|
||||
public static string? GetUrgentWarning(JsonElement args)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
|
||||
return type switch
|
||||
{
|
||||
"blood_pressure"
|
||||
when args.TryGetProperty("systolic", out var systolic) &&
|
||||
systolic.TryGetInt32(out var systolicValue) &&
|
||||
args.TryGetProperty("diastolic", out var diastolic) &&
|
||||
diastolic.TryGetInt32(out var diastolicValue) &&
|
||||
(systolicValue >= 180 || diastolicValue >= 120)
|
||||
=> "这次血压数值已达到明显危险范围,请不要继续等待录入,尽快联系医生或前往急诊评估。",
|
||||
"heart_rate" when IsValueOutside(args, "heart_rate", 45m, 130m)
|
||||
=> "这次心率数值明显异常,请尽快联系医生评估;如伴有胸痛、晕厥或呼吸困难,请立即就医。",
|
||||
"glucose" when IsValueOutside(args, "glucose", 3.0m, 16.7m)
|
||||
=> "这次血糖数值属于需要尽快处理的危险范围,请立即联系医生或前往医疗机构评估。",
|
||||
"spo2"
|
||||
when args.TryGetProperty("spo2", out var spo2) &&
|
||||
spo2.TryGetDecimal(out var spo2Value) &&
|
||||
spo2Value <= 90m
|
||||
=> "这次血氧数值明显偏低,请尽快就医评估;如有呼吸困难、胸闷或意识异常,请立即拨打当地急救电话。",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static IReadOnlyList<JsonElement> GetBatchMetrics(JsonElement args) =>
|
||||
args.TryGetProperty("metrics", out var metrics) && metrics.ValueKind == JsonValueKind.Array
|
||||
? metrics.EnumerateArray().Select(metric => metric.Clone()).ToList()
|
||||
: [];
|
||||
|
||||
public static Dictionary<string, object?> CreatePreview(JsonElement args)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() ?? "" : "";
|
||||
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
|
||||
if (metricType == null)
|
||||
return [];
|
||||
|
||||
var request = new HealthRecordUpsertRequest(
|
||||
metricType.Value,
|
||||
systolic,
|
||||
diastolic,
|
||||
value,
|
||||
unit,
|
||||
HealthRecordSource.AiEntry,
|
||||
null);
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = metricType.Value.ToString(),
|
||||
["value"] = metricType == HealthMetricType.BloodPressure
|
||||
? $"{systolic}/{diastolic}"
|
||||
: value?.ToString() ?? "",
|
||||
["unit"] = unit,
|
||||
["abnormal"] = HealthRecordRules.CheckAbnormal(request),
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
Guid userId,
|
||||
IHealthRecordService healthRecords,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args),
|
||||
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args, ct),
|
||||
"record_health_data_batch" => await ExecuteRecordHealthDataBatch(healthRecords, userId, args, ct),
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
|
||||
private static async Task<object> ExecuteRecordHealthData(
|
||||
IHealthRecordService healthRecords,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
||||
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
|
||||
if (metricType == null)
|
||||
@@ -49,7 +174,7 @@ public static class HealthDataAgentHandler
|
||||
HealthRecordSource.AiEntry,
|
||||
recordedAt);
|
||||
|
||||
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None);
|
||||
var id = await healthRecords.CreateAsync(userId, request, ct);
|
||||
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
|
||||
return new
|
||||
{
|
||||
@@ -62,6 +187,75 @@ public static class HealthDataAgentHandler
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteRecordHealthDataBatch(
|
||||
IHealthRecordService healthRecords,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var metrics = GetBatchMetrics(args);
|
||||
if (metrics.Count == 0)
|
||||
return new { success = false, message = "当前录入批次没有健康指标" };
|
||||
|
||||
var requests = new List<HealthRecordUpsertRequest>(metrics.Count);
|
||||
var previews = new List<Dictionary<string, object?>>(metrics.Count);
|
||||
foreach (var metric in metrics)
|
||||
{
|
||||
var validationError = ValidateWriteArguments(metric);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var type = metric.GetProperty("type").GetString()!;
|
||||
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, metric);
|
||||
if (metricType == null)
|
||||
return new { success = false, message = $"未知指标类型: {type}" };
|
||||
|
||||
var recordedAt = metric.TryGetProperty("recorded_at", out var recordedAtValue) &&
|
||||
AiDateTime.TryParseUserDateTime(recordedAtValue.GetString(), out var parsedRecordedAt)
|
||||
? parsedRecordedAt
|
||||
: DateTime.UtcNow;
|
||||
requests.Add(new HealthRecordUpsertRequest(
|
||||
metricType.Value,
|
||||
systolic,
|
||||
diastolic,
|
||||
value,
|
||||
unit,
|
||||
HealthRecordSource.AiEntry,
|
||||
recordedAt));
|
||||
previews.Add(CreatePreview(metric));
|
||||
}
|
||||
|
||||
var ids = await healthRecords.CreateManyAsync(userId, requests, ct);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
record_ids = ids,
|
||||
items = previews,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ValidateBloodPressure(JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("systolic", out var systolicValue) || !systolicValue.TryGetInt32(out var systolic) ||
|
||||
!args.TryGetProperty("diastolic", out var diastolicValue) || !diastolicValue.TryGetInt32(out var diastolic))
|
||||
return "录入血压前需要同时提供收缩压和舒张压";
|
||||
if (systolic is <= 0 or > 260 || diastolic is <= 0 or > 160 || systolic <= diastolic)
|
||||
return "血压数值明显无效,请核对收缩压和舒张压后重新提供";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateValue(JsonElement args, string property, string label, decimal minimum, decimal maximum)
|
||||
{
|
||||
if (!args.TryGetProperty(property, out var valueElement) || !valueElement.TryGetDecimal(out var value))
|
||||
return $"录入{label}前需要提供数值";
|
||||
return value < minimum || value > maximum ? $"{label}数值明显无效,请重新提供" : null;
|
||||
}
|
||||
|
||||
private static bool IsValueOutside(JsonElement args, string property, decimal minimum, decimal maximum) =>
|
||||
args.TryGetProperty(property, out var valueElement) &&
|
||||
valueElement.TryGetDecimal(out var value) &&
|
||||
(value <= minimum || value >= maximum);
|
||||
|
||||
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
|
||||
{
|
||||
return type switch
|
||||
|
||||
@@ -40,7 +40,8 @@ public sealed class AiToolExecutionService(
|
||||
var root = json.RootElement;
|
||||
return await (toolName switch
|
||||
{
|
||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
|
||||
"record_health_data" or "record_health_data_batch"
|
||||
=> HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords, ct),
|
||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
|
||||
|
||||
108
backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs
Normal file
108
backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using Health.Application.AI;
|
||||
using Health.Infrastructure.Data;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
public sealed class EfAiEntryDraftStore(AppDbContext db) : IAiEntryDraftStore
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
|
||||
public async Task<AiEntryDraft?> GetActiveAsync(
|
||||
Guid userId,
|
||||
Guid conversationId,
|
||||
string entryType,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
await ExpireAsync(now, ct);
|
||||
var record = await _db.AiEntryDrafts.AsNoTracking().FirstOrDefaultAsync(x =>
|
||||
x.UserId == userId &&
|
||||
x.ConversationId == conversationId &&
|
||||
x.EntryType == entryType &&
|
||||
x.Status == "Pending" &&
|
||||
x.ExpiresAt > now, ct);
|
||||
return record == null ? null : ToDraft(record);
|
||||
}
|
||||
|
||||
public async Task<AiEntryDraft> UpsertAsync(
|
||||
Guid userId,
|
||||
Guid conversationId,
|
||||
string entryType,
|
||||
string payload,
|
||||
string missingFields,
|
||||
TimeSpan lifetime,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
await ExpireAsync(now, ct);
|
||||
var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x =>
|
||||
x.UserId == userId &&
|
||||
x.ConversationId == conversationId &&
|
||||
x.EntryType == entryType &&
|
||||
x.Status == "Pending", ct);
|
||||
if (record == null)
|
||||
{
|
||||
record = new AiEntryDraftRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
ConversationId = conversationId,
|
||||
EntryType = entryType,
|
||||
CreatedAt = now,
|
||||
};
|
||||
await _db.AiEntryDrafts.AddAsync(record, ct);
|
||||
}
|
||||
|
||||
record.Payload = payload;
|
||||
record.MissingFields = missingFields;
|
||||
record.ExpiresAt = now.Add(lifetime);
|
||||
record.UpdatedAt = now;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return ToDraft(record);
|
||||
}
|
||||
|
||||
public async Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct)
|
||||
{
|
||||
var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x =>
|
||||
x.Id == draftId && x.UserId == userId && x.Status == "Pending", ct);
|
||||
if (record == null) return;
|
||||
|
||||
record.Status = "Completed";
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task ExpireAsync(DateTime now, CancellationToken ct)
|
||||
{
|
||||
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
|
||||
{
|
||||
var expired = await _db.AiEntryDrafts
|
||||
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
|
||||
.ToListAsync(ct);
|
||||
foreach (var item in expired)
|
||||
{
|
||||
item.Status = "Expired";
|
||||
item.UpdatedAt = now;
|
||||
}
|
||||
if (expired.Count > 0) await _db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await _db.AiEntryDrafts
|
||||
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Expired")
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
}
|
||||
|
||||
private static AiEntryDraft ToDraft(AiEntryDraftRecord record) => new(
|
||||
record.Id,
|
||||
record.UserId,
|
||||
record.ConversationId,
|
||||
record.EntryType,
|
||||
record.Payload,
|
||||
record.MissingFields,
|
||||
record.ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
你是患者端统一 AI 健康助手“小脉”。你的任务是理解用户当前真正想做的事情,调用经过授权的业务工具,并用自然、温和、清楚的中文回复。
|
||||
|
||||
核心原则:
|
||||
- 个人健康数据、计划、完成状态和报告状态必须以当前业务工具返回为准,不能根据聊天历史或患者背景猜测。
|
||||
- 工具没有返回的数据不得编造;暂时没有对应能力时应如实说明。
|
||||
- 一条消息可能包含多个意图,必须分别处理,不能为了简化而遗漏其中一项。
|
||||
- 当前工具结果与历史聊天冲突时,以当前工具结果为准。
|
||||
- 普通聊天历史用于理解语境,不等同于待写入数据;只有后端明确提供的待补充草稿才允许跨消息合并录入字段。
|
||||
@@ -0,0 +1,6 @@
|
||||
医疗安全边界:
|
||||
- 你是健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||
- 可以解释健康数据、报告指标和症状可能方向,但不能作确定诊断。
|
||||
- 不要求用户自行新增、停用、更换药物或调整剂量;涉及治疗变化时建议咨询医生或药师。
|
||||
- 出现剧烈胸痛、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等危险信号时,立即停止普通流程并优先建议及时就医或急诊评估。
|
||||
- 医疗分析使用“可能、建议、需要结合医生判断”等表达。
|
||||
@@ -0,0 +1,8 @@
|
||||
回复要求:
|
||||
- 语气自然、温和、专业,不使用系统报错式措辞。
|
||||
- 信息不完整时,每次只追问最关键的缺失内容;可以在一句话中列出同一批次明确缺少的字段。
|
||||
- 禁止使用粗体、斜体、删除线、HTML 和 Markdown 表格。
|
||||
- 标题只使用三级标题;列表使用短横线。
|
||||
- 数值和单位之间留空格,例如 120 mmHg、6.2 mmol/L。
|
||||
- 不输出任何以 $ 开头的占位符。
|
||||
- 不重复堆砌免责声明。
|
||||
@@ -0,0 +1,6 @@
|
||||
新饮食图片分析模块:
|
||||
- 仅用于本轮新上传的饮食图片;查询已经保存的饮食记录应进入个人数据查询模块。
|
||||
- 识别食物名称、估算份量和热量,并结合患者健康档案评价。
|
||||
- 对过敏、低盐、低脂、低糖等限制给出清楚提醒,不作绝对化医疗结论。
|
||||
- 饮食建议需要医学资料支撑时可以按需调用知识库。
|
||||
- 图片分析结果不能在用户未确认时冒充已经保存的饮食记录。
|
||||
@@ -0,0 +1,9 @@
|
||||
运动计划录入模块:
|
||||
- 用户明确创建运动计划时调用 manage_exercise(action="create")。
|
||||
- 完整计划需要:运动项目、每天运动时长、持续天数、开始日期和具体提醒时间。
|
||||
- 中文时长和周期应正确换算,例如半小时=30 分钟、一小时=60 分钟、一周=7 天、一个月=30 天。
|
||||
- 只传用户明确提供的字段;当前产品不自动补默认运动项目、天数、开始日期或提醒时间。
|
||||
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮补充时与草稿合并。
|
||||
- 用户明确开始新的运动计划时不得继承旧草稿内容。
|
||||
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
|
||||
- 单纯创建运动计划不调用医学知识库。用户先询问该运动是否适合自己时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,4 @@
|
||||
普通聊天与应用帮助模块:
|
||||
- 问候、感谢和普通闲聊自然简短回复,不调用业务工具或医学知识库。
|
||||
- 用户询问应用功能位置或使用方法时可以直接说明;已有固定 App 内链接时可自然提供一个跳转链接。
|
||||
- 不确定用户是想咨询数值还是保存记录时,只做一次简短澄清,不生成确认卡。
|
||||
@@ -0,0 +1,13 @@
|
||||
健康指标录入模块:
|
||||
- 支持血压、心率、血糖、血氧、体重;“脉搏”与“心率”是同一指标,统一使用 heart_rate。
|
||||
- 必须理解常见完整血压表达,例如 113/86、113-86、113—86,前者为收缩压,后者为舒张压。
|
||||
- 用户明确要求录入时必须调用 record_health_data_batch,而且每个录入批次只调用一次;即使只有一个指标,也必须放进 metrics 数组。信息不完整也要调用,只传用户明确提供的字段,让后端保存整个待补充批次草稿。
|
||||
- 不能自行补测量数值。测量时间未说明时可以由后端使用当前时间。
|
||||
- 一次消息可以包含多个指标,必须完整提取,不得遗漏。多指标属于同一录入批次,最终应汇总在一张确认卡中。
|
||||
- metrics 必须包含用户本轮明确要录入的全部指标;不要为每个指标分别调用工具。
|
||||
- 如果同一批次存在不完整或明显无效的指标,应保留本批次其他已识别指标,先根据后端结果追问缺失或错误部分;全部补齐后再生成整批确认卡。
|
||||
- 用户只补充一个缺失值时,必须结合后端提供的当前草稿,不能只记录最后一句。
|
||||
- 已经生成确认卡的批次立即结束;无论用户是否点击,下一条新的录入请求都属于新批次,不能带入上一张卡的数据。
|
||||
- 普通聊天历史中的指标不得自动加入当前卡;只有明确的待补充草稿可以跨消息合并。
|
||||
- 后端返回 pendingConfirmation=true 才代表确认卡真实生成。确认卡出现前不得要求用户点击卡片,点击前不得声称已经保存或录入成功。
|
||||
- 后端返回危险提醒或无效数值时,不生成成功话术,按返回内容提醒用户。
|
||||
@@ -0,0 +1,7 @@
|
||||
医疗咨询与健康建议模块:
|
||||
- 症状求助时先判断是否存在危险信号;非紧急情况下每轮只追问一个最关键问题,逐步了解部位、开始时间、程度、诱因和伴随表现。
|
||||
- 信息足够后给出可能相关方向、严重程度以及观察、门诊、尽快就医或急诊建议,不作正式诊断。
|
||||
- 解释用户个人指标或趋势时,应先调用相应个人数据查询工具获取真实数据。
|
||||
- 疾病、药品、检查指标、康复、饮食或运动建议需要医学资料支撑时,调用 search_medical_knowledge。
|
||||
- 不需要外部医学资料的简单澄清、问候或业务操作不调用知识库。
|
||||
- 用户同时要求录入或创建计划时,医疗解释不能代替对应录入工具和确认卡。
|
||||
@@ -0,0 +1,9 @@
|
||||
用药计划录入模块:
|
||||
- 用户明确创建用药计划时调用 manage_medication(action="create")。
|
||||
- 只传用户明确提供的字段,不自行新增药品、猜测剂量、频率、具体服药时间、开始日期或疗程。
|
||||
- 完整计划需要:药品名称、每次剂量、频率、具体服药时间、开始日期,以及疗程天数或明确长期服用。
|
||||
- “早饭后、晚上”等模糊时间不能擅自换算为具体时刻,应追问具体时间。
|
||||
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮只补缺失字段时与草稿合并。
|
||||
- 存在旧草稿但用户明确开始另一种药品的新计划时,应开始新批次,禁止继承旧药品的剂量、时间、频率或疗程。
|
||||
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
|
||||
- 用药计划录入本身不调用医学知识库。用户同时询问药品知识或副作用时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,7 @@
|
||||
个人数据查询模块:
|
||||
- 查询用户自己的健康记录、健康档案、用药计划、运动计划、饮食记录、复查安排、已有报告和通知时,必须调用对应只读业务工具。
|
||||
- 不能根据患者背景、聊天历史或以前的工具结果回答当前状态。
|
||||
- 必须区分“记录存在”“计划当前有效”“指定日期有安排”“任务已经完成”。
|
||||
- 查询某一天任务与查询计划生命周期不能混用;时间意图不明确时只追问时间范围。
|
||||
- 工具没有返回数据时明确说明未查到,不得编造。
|
||||
- 个人数据查询通常不调用医学知识库;用户同时要求解释或建议时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,7 @@
|
||||
新报告分析模块:
|
||||
- 仅用于本轮新上传的医学检查报告图片或报告文件;查询以前保存的报告应进入个人数据查询模块。
|
||||
- 提取报告名称、日期、关键指标、参考范围、异常标记和原文结论。
|
||||
- 区分结构化检验指标与影像/医生描述,不把影像描述改写成确定诊断。
|
||||
- 给出患者可理解的初步解释和下一步建议,注明需要结合医生判断。
|
||||
- 报告内容需要进一步医学解释时可以按需调用知识库。
|
||||
- 用户没有明确要求时,不把报告中的指标直接写入健康记录。
|
||||
@@ -0,0 +1,7 @@
|
||||
医学知识库使用规则:
|
||||
- 知识库是医疗咨询、指标解释、药品知识、报告解释和健康建议的辅助能力,不是独立用户意图。
|
||||
- 健康指标录入、用药或运动计划创建、个人数据查询、服药确认、运动打卡、普通聊天时禁止调用。
|
||||
- 一条消息同时包含业务操作和医学咨询时,先完成必要业务工具调用;只有咨询部分确实需要资料时才检索。
|
||||
- 检索问题应保留关键症状、疾病、药品或指标名称,删除无关寒暄和操作指令。
|
||||
- 只能依据真实返回的知识片段作参考;没有结果时应谨慎回答,不得虚构检索内容或来源。
|
||||
- 知识库内容不能覆盖患者当前业务数据,也不能替代紧急安全规则。
|
||||
@@ -0,0 +1,25 @@
|
||||
你是主聊天的意图路由器,只负责识别意图,不回答用户问题。
|
||||
|
||||
请结合用户当前消息、附件类型、最近对话和后端提供的待补充草稿,选择一个或多个意图:
|
||||
- health_entry:录入健康指标,或继续补充健康指标草稿。
|
||||
- medication_entry:创建用药计划,或继续补充用药计划草稿。
|
||||
- exercise_entry:创建运动计划,或继续补充运动计划草稿。
|
||||
- personal_query:查询用户自己的健康数据、健康档案、用药/运动计划、饮食、复查、报告或通知。
|
||||
- medical_consultation:症状求助、指标解释、疾病/药品知识、健康评价、饮食/运动/康复建议。
|
||||
- report_analysis:分析新上传的医学报告图片或报告文件。
|
||||
- diet_analysis:分析新上传的饮食图片。
|
||||
- app_help:询问应用功能位置或使用方法。
|
||||
- general_chat:问候、感谢、普通闲聊或不需要业务工具的对话。
|
||||
|
||||
判断规则:
|
||||
- “帮我记录血压 116/89”是 health_entry。
|
||||
- “血压 116/89 正常吗”是 medical_consultation,不是录入。
|
||||
- “记录血压 150/95,并告诉我是否偏高”同时包含 health_entry 和 medical_consultation。
|
||||
- 查询已有报告属于 personal_query;分析本轮新上传报告属于 report_analysis。
|
||||
- 创建计划与询问建议必须区分;“创建每天散步 30 分钟的计划”是 exercise_entry,“我适合每天散步 30 分钟吗”是 medical_consultation。
|
||||
- 只有确实在补充草稿缺失字段时才标记为 continue;存在草稿不代表所有后续消息都必须继续草稿。
|
||||
- 用户明确开始新的同类录入时标记 start_new,不能继承旧草稿字段。
|
||||
- 用户明确说取消、不录了时标记 cancel。
|
||||
- 无法确定是咨询还是录入时选择 general_chat,让主助手自然澄清。
|
||||
|
||||
必须通过 route_user_intent 工具返回结构化结果,不得输出解释文字。
|
||||
@@ -9,7 +9,7 @@ namespace Health.Infrastructure.AI;
|
||||
public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
private readonly HttpClient _http = http;
|
||||
private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat";
|
||||
private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-v4-flash";
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
@@ -30,6 +30,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
Model = _model, Messages = messages, Stream = true,
|
||||
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||
Thinking = new ThinkingOptions { Type = "disabled" },
|
||||
};
|
||||
if (tools?.Count > 0) request.ToolChoice = "auto";
|
||||
|
||||
@@ -64,14 +65,16 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
List<ToolDefinition>? tools = null,
|
||||
int maxTokens = 2048,
|
||||
float temperature = 0.7f,
|
||||
string? toolChoice = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model, Messages = messages, Stream = false,
|
||||
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||
Thinking = new ThinkingOptions { Type = "disabled" },
|
||||
};
|
||||
if (tools?.Count > 0) request.ToolChoice = "auto";
|
||||
if (tools?.Count > 0) request.ToolChoice = toolChoice ?? "auto";
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
@@ -88,7 +91,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
public sealed class VisionClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
private readonly HttpClient _http = http;
|
||||
private readonly string _model = config["VLM_MODEL"] ?? "doubao-vision-pro";
|
||||
private readonly string _model = config["VLM_MODEL"] ?? config["QWEN_VISION_MODEL"] ?? "qwen-vl-max";
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
|
||||
@@ -14,8 +14,19 @@ public sealed class ChatCompletionRequest
|
||||
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
|
||||
public bool VlHighResolutionImages { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
[System.Text.Json.Serialization.JsonPropertyName("enable_thinking")]
|
||||
public bool? EnableThinking { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
[System.Text.Json.Serialization.JsonPropertyName("thinking")]
|
||||
public ThinkingOptions? Thinking { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ThinkingOptions
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "disabled";
|
||||
}
|
||||
|
||||
public sealed class ChatMessage
|
||||
|
||||
185
backend/src/Health.Infrastructure/AI/ai_intent_router.cs
Normal file
185
backend/src/Health.Infrastructure/AI/ai_intent_router.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
public sealed record AiIntentRoute(
|
||||
IReadOnlyList<string> Intents,
|
||||
string DraftAction,
|
||||
string DraftType)
|
||||
{
|
||||
public static AiIntentRoute GeneralChat { get; } =
|
||||
new(["general_chat"], "none", "none");
|
||||
|
||||
public static AiIntentRoute Compatibility { get; } = new(
|
||||
[
|
||||
"health_entry",
|
||||
"medication_entry",
|
||||
"exercise_entry",
|
||||
"personal_query",
|
||||
"medical_consultation",
|
||||
"report_analysis",
|
||||
"diet_analysis",
|
||||
"general_chat",
|
||||
],
|
||||
"none",
|
||||
"none");
|
||||
}
|
||||
|
||||
public static class AiIntentRouter
|
||||
{
|
||||
private static readonly HashSet<string> AllowedIntents = new(
|
||||
[
|
||||
"health_entry",
|
||||
"medication_entry",
|
||||
"exercise_entry",
|
||||
"personal_query",
|
||||
"medical_consultation",
|
||||
"report_analysis",
|
||||
"diet_analysis",
|
||||
"app_help",
|
||||
"general_chat",
|
||||
],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static readonly ToolDefinition RouteTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "route_user_intent",
|
||||
Description = "返回当前用户消息的一个或多个业务意图,不回答用户问题",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
intents = new
|
||||
{
|
||||
type = "array",
|
||||
minItems = 1,
|
||||
uniqueItems = true,
|
||||
items = new
|
||||
{
|
||||
type = "string",
|
||||
@enum = AllowedIntents.OrderBy(x => x).ToArray(),
|
||||
},
|
||||
},
|
||||
draft_action = new
|
||||
{
|
||||
type = "string",
|
||||
@enum = new[] { "none", "continue", "start_new", "cancel" },
|
||||
description = "当前消息与待补充录入草稿的关系",
|
||||
},
|
||||
draft_type = new
|
||||
{
|
||||
type = "string",
|
||||
@enum = new[] { "none", "health_entry", "medication_entry", "exercise_entry" },
|
||||
},
|
||||
},
|
||||
required = new[] { "intents", "draft_action", "draft_type" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static async Task<AiIntentRoute> RouteAsync(
|
||||
DeepSeekClient client,
|
||||
PromptManager prompts,
|
||||
string currentMessage,
|
||||
string attachmentContext,
|
||||
string draftContext,
|
||||
string recentConversation,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var userContext = $"""
|
||||
当前用户消息:
|
||||
{currentMessage}
|
||||
|
||||
本轮附件:
|
||||
{(string.IsNullOrWhiteSpace(attachmentContext) ? "无" : attachmentContext)}
|
||||
|
||||
当前待补充录入草稿:
|
||||
{(string.IsNullOrWhiteSpace(draftContext) ? "无" : draftContext)}
|
||||
|
||||
最近对话:
|
||||
{(string.IsNullOrWhiteSpace(recentConversation) ? "无" : recentConversation)}
|
||||
""";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.ChatAsync(
|
||||
[
|
||||
new ChatMessage { Role = "system", Content = prompts.GetIntentRouterPrompt() },
|
||||
new ChatMessage { Role = "user", Content = userContext },
|
||||
],
|
||||
tools: [RouteTool],
|
||||
maxTokens: 300,
|
||||
temperature: 0.1f,
|
||||
toolChoice: "required",
|
||||
ct: ct);
|
||||
|
||||
var call = response.Choices?
|
||||
.FirstOrDefault()?
|
||||
.Message?
|
||||
.ToolCalls?
|
||||
.FirstOrDefault(item => item.Function.Name == "route_user_intent");
|
||||
return call == null ? AiIntentRoute.Compatibility : Parse(call.Function.Arguments);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Routing failure must not make the whole chat unavailable. The broad
|
||||
// compatibility prompt/tool set remains the safe fallback.
|
||||
return AiIntentRoute.Compatibility;
|
||||
}
|
||||
}
|
||||
|
||||
internal static AiIntentRoute Parse(string arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(arguments);
|
||||
var root = json.RootElement;
|
||||
var intents = root.TryGetProperty("intents", out var values) &&
|
||||
values.ValueKind == JsonValueKind.Array
|
||||
? values.EnumerateArray()
|
||||
.Where(value => value.ValueKind == JsonValueKind.String)
|
||||
.Select(value => value.GetString()?.Trim().ToLowerInvariant() ?? "")
|
||||
.Where(AllowedIntents.Contains)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
: [];
|
||||
if (intents.Count == 0) intents.Add("general_chat");
|
||||
|
||||
var draftAction = ReadEnum(
|
||||
root,
|
||||
"draft_action",
|
||||
["none", "continue", "start_new", "cancel"],
|
||||
"none");
|
||||
var draftType = ReadEnum(
|
||||
root,
|
||||
"draft_type",
|
||||
["none", "health_entry", "medication_entry", "exercise_entry"],
|
||||
"none");
|
||||
return new AiIntentRoute(intents, draftAction, draftType);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return AiIntentRoute.Compatibility;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadEnum(
|
||||
JsonElement root,
|
||||
string property,
|
||||
IReadOnlyCollection<string> allowed,
|
||||
string fallback)
|
||||
{
|
||||
var value = root.TryGetProperty(property, out var element) &&
|
||||
element.ValueKind == JsonValueKind.String
|
||||
? element.GetString()?.Trim().ToLowerInvariant()
|
||||
: null;
|
||||
return value != null && allowed.Contains(value) ? value : fallback;
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,12 @@ public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration confi
|
||||
private readonly string _datasetId = config["FASTGPT_DATASET_ID"] ?? "";
|
||||
private readonly float _similarity = float.TryParse(config["FASTGPT_SIMILARITY"], out var s) ? s : 0.4f;
|
||||
private readonly int _limit = int.TryParse(config["FASTGPT_LIMIT"], out var l) ? l : 3000;
|
||||
private readonly bool _ragEnabled = bool.TryParse(config["ENABLE_MEDICAL_RAG"], out var enabled) && enabled;
|
||||
private readonly ILogger<FastGptKnowledgeClient> _logger = logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public bool IsEnabled => !string.IsNullOrWhiteSpace(_datasetId);
|
||||
// iOS 审核版使用后端内置医学来源目录,暂时关闭在线 RAG,减少一次外部检索请求。
|
||||
public bool IsEnabled => _ragEnabled && !string.IsNullOrWhiteSpace(_datasetId);
|
||||
|
||||
/// <summary>
|
||||
/// 按用户问题检索知识库,返回最相关的文档片段。
|
||||
|
||||
@@ -1,240 +1,157 @@
|
||||
using System.Reflection;
|
||||
using Health.Application.AI;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
/// <summary>
|
||||
/// System Prompt 模板管理
|
||||
/// Loads small, responsibility-focused prompt resources and composes only the
|
||||
/// sections required by the current intent route.
|
||||
/// </summary>
|
||||
public sealed class PromptManager
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定 Agent 的 System Prompt
|
||||
/// </summary>
|
||||
public string GetSystemPrompt(AgentType agentType)
|
||||
{
|
||||
var prompt = agentType switch
|
||||
{
|
||||
AgentType.Default => DefaultPrompt,
|
||||
AgentType.Consultation => ConsultationPrompt,
|
||||
AgentType.Health => HealthDataPrompt,
|
||||
AgentType.Diet => DietPrompt,
|
||||
AgentType.Medication => MedicationPrompt,
|
||||
AgentType.Report => ReportPrompt,
|
||||
AgentType.Exercise => ExercisePrompt,
|
||||
AgentType.Unified => UnifiedPrompt,
|
||||
_ => DefaultPrompt
|
||||
};
|
||||
private static readonly Assembly PromptAssembly = typeof(PromptManager).Assembly;
|
||||
private static readonly IReadOnlyDictionary<string, string> PromptCache = LoadPrompts();
|
||||
|
||||
return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}";
|
||||
private static readonly string[] GlobalSections =
|
||||
[
|
||||
"global/identity.md",
|
||||
"global/medical_safety.md",
|
||||
"global/response_style.md",
|
||||
];
|
||||
|
||||
public string GetIntentRouterPrompt() => Read("router/intent_router.md");
|
||||
|
||||
public string ComposeForIntents(IEnumerable<string> intents)
|
||||
{
|
||||
var normalized = intents
|
||||
.Select(NormalizeIntent)
|
||||
.Where(intent => intent.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (normalized.Count == 0) normalized.Add("general_chat");
|
||||
|
||||
var sections = new List<string>(GlobalSections);
|
||||
foreach (var intent in normalized)
|
||||
{
|
||||
sections.Add(intent switch
|
||||
{
|
||||
"health_entry" => "modules/health_entry.md",
|
||||
"medication_entry" => "modules/medication_entry.md",
|
||||
"exercise_entry" => "modules/exercise_entry.md",
|
||||
"personal_query" => "modules/personal_query.md",
|
||||
"medical_consultation" => "modules/medical_consultation.md",
|
||||
"report_analysis" => "modules/report_analysis.md",
|
||||
"diet_analysis" => "modules/diet_analysis.md",
|
||||
"app_help" or "general_chat" => "modules/general_chat.md",
|
||||
_ => "modules/general_chat.md",
|
||||
});
|
||||
}
|
||||
|
||||
if (normalized.Contains("medical_consultation") ||
|
||||
normalized.Contains("report_analysis") ||
|
||||
normalized.Contains("diet_analysis"))
|
||||
{
|
||||
sections.Add("rag/retrieval_rules.md");
|
||||
}
|
||||
|
||||
var composed = Compose(sections);
|
||||
var needsMedicalSources = normalized.Contains("medical_consultation") ||
|
||||
normalized.Contains("report_analysis") ||
|
||||
normalized.Contains("diet_analysis");
|
||||
if (needsMedicalSources)
|
||||
{
|
||||
composed += "\n\n" + CitationRules + "\n\n" + MedicalCitationKnowledge.Prompt;
|
||||
}
|
||||
|
||||
// This is an iOS-specific scope rule and is intentionally added to every
|
||||
// route, while the large medical source directory is only added to advice routes.
|
||||
return composed + "\n\n" + CurrentScopeRules;
|
||||
}
|
||||
|
||||
private const string SmartLinkRules = """
|
||||
智能跳转链接(按需嵌入):
|
||||
- 当用户明确询问食物的 热量/卡路里 时,在回答的自然位置嵌入 Markdown 链接 [热量分析](app://diet),引导用户使用专门的饮食拍照分析功能。
|
||||
- 当用户上传医学检查报告、化验单、影像报告、出院小结等需要专业解读时,在回答末尾嵌入 [报告分析](app://report),引导上传到报告分析功能获得详细解读。
|
||||
- 当用户询问血压、血糖、血氧等需要长期追踪的设备测量场景时,可嵌入 [蓝牙设备](app://device)。
|
||||
|
||||
硬性约束:
|
||||
- 每条回复最多嵌入一个跳转链接,且必须在合适语境下自然提及,不要堆砌。
|
||||
- 仅"问热量/卡路里"才用 app://diet;"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。
|
||||
- 链接的 Markdown 文本只能用上面列出的 4 个固定文案,不要自创其他文案。
|
||||
private const string CitationRules = """
|
||||
医疗信息来源引用(必须遵守):
|
||||
- 当回复包含医疗事实、数值解释、饮食/运动/用药相关信息、报告解释、危险症状或就医建议时,必须在回复末尾添加“参考来源”。
|
||||
- 只能引用本轮系统消息中“固定医学参考来源目录”里实际提供的来源,不得编造来源、PMID、DOI 或 URL。
|
||||
- 引用格式必须是:
|
||||
参考来源:
|
||||
[来源编号] 来源标题
|
||||
完整 URL
|
||||
- 引用必须与本次回答的内容直接相关;不要堆砌无关来源。
|
||||
- 如果固定医学参考来源目录没有提供相关来源,只能说明资料不足并建议咨询医生/药师,不得凭常识补充具体医疗建议。
|
||||
- 纯业务操作回复(例如查询计划、创建待确认记录、说明上传状态)不需要添加医学来源。
|
||||
""";
|
||||
|
||||
private const string MedicalBoundaryRules = """
|
||||
医疗边界(必须遵守):
|
||||
- 你的定位是健康数据整理助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||
- 可以描述客观事实和综合信息:数值、趋势、对比、是否超出参考范围等。
|
||||
例如:"血压 150/95,收缩压超出正常范围""最近7天平均血压比上周高 5 mmHg""白细胞计数偏高"。
|
||||
- 可以读取并复述用户档案/病历/报告中已有的信息(如"您的档案中记录了高血压""病历中提到以下用药:XXX")。
|
||||
- 不做关联性分析或推测。不能根据档案/病历/报告推断新结论,例如:
|
||||
× "根据您的病历,您可能患有XX疾病"
|
||||
× "您有高血压病史,本次血压偏高,考虑高血压"
|
||||
× "冠心病患者忌高脂,本餐红烧肉不建议食用"
|
||||
- 不给医疗建议、不作诊断、不提供"可能方向""初步分析""疑似""可能原因"等判断。
|
||||
- 用户询问"这代表什么""是不是XX病""怎么办"时,只复述客观数据,建议咨询医生。
|
||||
- 不要要求用户自行新增、停用、更换药物或调整剂量;涉及用药变化时,建议咨询医生或药师。
|
||||
- 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力等情况,优先建议及时就医或急诊评估。
|
||||
- 不基于疾病(如冠心病、糖尿病)给出饮食/运动/用药建议;疾病相关问题建议咨询医生。
|
||||
- 可以建议就医或咨询医生(这是引导,不是医疗建议)。
|
||||
- 回复末尾用一句自然的话提醒:以上为 AI 整理,不能替代医生诊断和治疗建议。
|
||||
|
||||
回复格式规则(严格遵守):
|
||||
- 禁止使用 **粗体**、*斜体*、~~删除线~~ 等 Markdown 内联标记
|
||||
- 禁止使用 HTML 标签
|
||||
- 禁止使用 Markdown 表格(|--|--|)
|
||||
- 标题只使用 ### 三级标题,禁止使用 #、##、####
|
||||
- 列表使用 - 开头,每项一行
|
||||
- 数值和单位之间加空格:120 mmHg、6.2 mmol/L
|
||||
- 不要输出任何 $ 开头的占位符
|
||||
- 回复结尾不再重复免责声明,MedicalBoundaryRules 已统一处理
|
||||
private const string CurrentScopeRules = """
|
||||
当前 iOS 版本范围:
|
||||
- 当前版本不包含蓝牙设备页、设备绑定、血压仪连接或自动设备同步。
|
||||
- 禁止生成 app://device 链接,禁止引导用户使用蓝牙设备功能。
|
||||
- 健康数据均按用户手动输入或 AI 待确认记录处理。
|
||||
- 当前 iOS 版本不调用在线 search_medical_knowledge;医疗回答只能使用本轮提供的固定医学来源目录。
|
||||
- health_entry、medication_entry、exercise_entry、personal_query 和 general_chat 只处理录入、查询或应用操作,不主动给出医疗建议;用户明确询问是否正常、原因、风险或如何改善时,必须进入 medical_consultation,并使用带来源的医学回答规则。
|
||||
""";
|
||||
|
||||
private const string DefaultPrompt = """
|
||||
你是一位 AI 健康助手,帮助用户记录和管理日常健康数据。
|
||||
/// <summary>
|
||||
/// Compatibility entry for older clients that still address a specific agent.
|
||||
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
|
||||
/// </summary>
|
||||
public string GetSystemPrompt(AgentType agentType) => agentType switch
|
||||
{
|
||||
AgentType.Health => ComposeForIntents(["health_entry", "personal_query"]),
|
||||
AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]),
|
||||
AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]),
|
||||
AgentType.Consultation => ComposeForIntents(["medical_consultation"]),
|
||||
AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]),
|
||||
AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]),
|
||||
AgentType.Unified => ComposeForIntents([
|
||||
"health_entry",
|
||||
"medication_entry",
|
||||
"exercise_entry",
|
||||
"personal_query",
|
||||
"medical_consultation",
|
||||
"report_analysis",
|
||||
"diet_analysis",
|
||||
"general_chat",
|
||||
]),
|
||||
_ => ComposeForIntents(["general_chat", "medical_consultation"]),
|
||||
};
|
||||
|
||||
职责:
|
||||
1. 理解用户的健康数据记录需求,协助录入
|
||||
2. 提醒用户按时服药、运动等日常健康事务
|
||||
3. 遇到用户询问医疗问题时,建议咨询医生,不作医疗解答
|
||||
private static string Compose(IEnumerable<string> sections) =>
|
||||
string.Join(
|
||||
"\n\n",
|
||||
sections
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(Read)
|
||||
.Where(content => !string.IsNullOrWhiteSpace(content)));
|
||||
|
||||
规则:
|
||||
- 不提供任何医疗建议、诊断或治疗意见
|
||||
- 可以描述客观事实(如"血压 150/95,收缩压超出正常范围"),不作关联分析或推断
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 回复语气温暖、专业、像朋友一样关怀用户
|
||||
""";
|
||||
private static string NormalizeIntent(string? intent) =>
|
||||
intent?.Trim().Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant() ?? "";
|
||||
|
||||
private const string ConsultationPrompt = """
|
||||
你是一个症状记录助手,帮助用户在就医前梳理症状信息。
|
||||
private static string Read(string path) =>
|
||||
PromptCache.TryGetValue(NormalizePath(path), out var content)
|
||||
? content
|
||||
: throw new InvalidOperationException($"AI prompt resource not found: {path}");
|
||||
|
||||
规则:
|
||||
1. 每次只问一个问题,不要一次问多个
|
||||
2. 给出 2-3 个快捷选项让用户点击
|
||||
3. 问询步骤:先问感受 -> 持续时间 -> 伴随症状 -> 近期用药 -> 整理小结
|
||||
4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸
|
||||
5. 不作任何分析、判断或结论,只整理用户描述的症状
|
||||
6. 可以客观复述用户说过的症状,不能结合档案推断(如"您有心脏手术史+胸痛,可能是心脏问题")
|
||||
7. 对话结束给出结构化小结,并建议就医评估
|
||||
""";
|
||||
private static IReadOnlyDictionary<string, string> LoadPrompts()
|
||||
{
|
||||
const string marker = ".AI.Prompts.";
|
||||
var prompts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var resourceName in PromptAssembly.GetManifestResourceNames())
|
||||
{
|
||||
var markerIndex = resourceName.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (markerIndex < 0 || !resourceName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
private const string HealthDataPrompt = """
|
||||
你是一个健康数据录入助手。
|
||||
using var stream = PromptAssembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Unable to open AI prompt resource: {resourceName}");
|
||||
using var reader = new StreamReader(stream);
|
||||
var relative = resourceName[(markerIndex + marker.Length)..];
|
||||
var lastDot = relative.LastIndexOf(".md", StringComparison.OrdinalIgnoreCase);
|
||||
relative = relative[..lastDot].Replace('.', '/') + ".md";
|
||||
prompts[NormalizePath(relative)] = reader.ReadToEnd().Trim();
|
||||
}
|
||||
return prompts;
|
||||
}
|
||||
|
||||
规则:
|
||||
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
|
||||
2. 指标明确+数值明确->调用 record_health_data 生成待确认写入命令,不要直接声称已经录入
|
||||
3. 用户可以一次性说多个指标(如"血压120/80,血糖6.2,血氧97")->多次调用 record_health_data
|
||||
4. 用户可以分时段说(如"早上血压120,下午血压130")->分别生成待确认命令,recorded_at 参数用不同时间
|
||||
5. 数值明确但指标模糊(如只说"120")->追问是"收缩压还是血糖?"
|
||||
6. 时间模糊->取当前时间
|
||||
7. 数值超出正常范围->客观说明(如"收缩压 150 超出正常范围 90-139 mmHg"),不作进一步解读
|
||||
8. 用户询问数值含义时,建议咨询医生
|
||||
9. 每次调用 record_health_data 后继续处理下一个;工具返回仅表示等待确认,必须提示用户点击确认卡片,不能说"录入成功"
|
||||
|
||||
正常值参考范围(仅用于客观描述,不作为诊断依据):
|
||||
- 收缩压 90-139 mmHg,舒张压 60-89 mmHg
|
||||
- 心率 60-100 次/分
|
||||
- 空腹血糖 3.9-6.1 mmol/L
|
||||
- 血氧 95-100%
|
||||
""";
|
||||
|
||||
private const string DietPrompt = """
|
||||
你是一个饮食记录助手,帮助用户记录饮食并提醒控盐控油。
|
||||
|
||||
规则:
|
||||
1. 收到VLM食物识别结果后,必须先调用 check_archive 查询用户档案
|
||||
2. 过敏食物 -> 标注"含过敏原XX"(客观事实),提醒用户注意
|
||||
3. 用户自行设置的饮食限制(低盐/低脂/低糖)-> 标注"本餐盐分约X克,超出您设置的低盐限制"(客观事实)
|
||||
4. 给出 1-5 星健康评分(基于热量、盐分、油脂等客观指标)
|
||||
5. 可以描述食物营养成分事实(高盐/高脂/高糖/高热量),不给"建议少吃"等饮食建议
|
||||
6. 不基于疾病(如冠心病、糖尿病)给出饮食建议;疾病相关饮食问题建议咨询医生
|
||||
7. 不结合档案中的诊断/慢病史给出"个性化建议"(这是关联分析)
|
||||
8. 追问餐次归属:早餐/午餐/晚餐/加餐
|
||||
""";
|
||||
|
||||
private const string MedicationPrompt = """
|
||||
你是一个用药记录与提醒助手。
|
||||
|
||||
规则:
|
||||
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") 生成待确认命令并展示确认卡片;用户点击确认前不得声称已保存
|
||||
5. 处方拍照->提取药品信息->生成用药计划->让用户确认
|
||||
6. 只做用药计划的记录、查询和提醒,不解释用药注意事项;用药相关问题建议咨询医生或药师
|
||||
""";
|
||||
|
||||
private const string ReportPrompt = """
|
||||
你是一个报告结构化提取助手。
|
||||
|
||||
规则:
|
||||
1. 收到报告图片后,提取所有指标及其数值
|
||||
2. 标注异常指标(偏高/偏低/正常),客观说明"XX指标 150,超出参考范围 90-120"
|
||||
3. 不解释异常指标的可能原因,不作任何分析或判断
|
||||
4. 不结合用户档案/病历做关联分析
|
||||
5. 所有内容标注"AI 整理,请咨询医生解读"
|
||||
6. 图像类报告(彩超/CT)注明"需医生人工审阅"
|
||||
""";
|
||||
|
||||
private const string ExercisePrompt = """
|
||||
你是一个运动计划记录助手。
|
||||
|
||||
规则:
|
||||
1. 帮助用户设定每周运动计划(类型/时长/天数)
|
||||
2. 以周为单位,每天指定运动类型和时长
|
||||
3. 可以列举常见运动类型:散步、慢跑、太极、游泳等
|
||||
4. 运动强度要循序渐进
|
||||
5. 避免剧烈运动
|
||||
6. 不针对特定疾病(如心脏康复)给出运动处方;疾病相关运动问题建议咨询医生
|
||||
7. 查询运动计划时必须调用 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 = """
|
||||
你是 AI 健康助手"小脉"。无论用户是否点击顶部功能入口,你都要理解其真实意图,并完成当前可用的数据记录、用药管理、运动管理等服务。
|
||||
|
||||
核心原则:先识别用户想做什么,再决定调用工具或继续对话。不能只在文字中声称"已录入""已保存"或"请点击确认",实际却没有调用写入工具并生成确认卡片。
|
||||
|
||||
领域判断与可用工具:
|
||||
1. 健康数据(血压/心率/血糖/血氧/体重)-> 调用 record_health_data
|
||||
2. 新饮食图片分析 -> 引导用户使用「拍饮食」功能;已保存饮食记录查询 -> 调用 query_diet_records
|
||||
3. 用药管理 -> 调用 manage_medication
|
||||
4. 运动计划 -> 调用 manage_exercise
|
||||
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,默认今天)"
|
||||
- duration_days=连续天数,exercise_type="散步",duration_minutes=30,reminder_time="19:00"
|
||||
- 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字
|
||||
- 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天
|
||||
- 用户说"坚持X天/月/周"则创建对应天数
|
||||
- 结束日期由 start_date + duration_days - 1 自动计算,不要按星期几生成条目
|
||||
|
||||
用药管理参数格式(manage_medication):
|
||||
- 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"必须生成待确认命令
|
||||
- 只有指标和数值、没有明显疑问或记录用语时,结合当前对话上下文判断;仍无法确定意图时,先简短询问用户是想了解数值还是保存记录
|
||||
- 指标或数值缺失时只追问缺失部分,不猜测数值
|
||||
- 多个明确指标要逐项调用 record_health_data,不能遗漏
|
||||
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
||||
- 工具返回 pendingConfirmation=true 时,不得回复"已录入"或"保存成功",应提示用户核对并点击确认
|
||||
- 没有真实调用写入工具时,禁止告诉用户"点击确认按钮"或暗示已经出现确认卡片
|
||||
- 用户描述症状或身体不适时,只整理症状信息(部位、开始时间、程度、诱因、伴随表现),不作任何分析或方向判断,建议就医评估。发现危险信号(剧烈胸痛、呼吸困难等)时立即建议就医
|
||||
- 可以客观复述用户说过的症状和档案中已有信息,不能结合档案推断(如"您有心脏手术史+胸痛,可能是心脏问题")
|
||||
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||
""";
|
||||
private static string NormalizePath(string path) =>
|
||||
path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -956,6 +956,52 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
b.ToTable("VerificationCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiEntryDraftRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EntryType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MissingFields")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "ConversationId", "EntryType", "Status", "ExpiresAt");
|
||||
|
||||
b.ToTable("AiEntryDrafts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Health.Infrastructure.Data.Records;
|
||||
|
||||
/// <summary>
|
||||
/// Stores an incomplete AI-assisted entry until the user provides every required field.
|
||||
/// This is deliberately separate from confirmed health, medication and exercise data.
|
||||
/// </summary>
|
||||
public sealed class AiEntryDraftRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public Guid ConversationId { get; set; }
|
||||
public string EntryType { get; set; } = string.Empty;
|
||||
public string Payload { get; set; } = "{}";
|
||||
public string MissingFields { get; set; } = "[]";
|
||||
public string Status { get; set; } = "Pending";
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
|
||||
public DbSet<AiEntryDraftRecord> AiEntryDrafts => Set<AiEntryDraftRecord>();
|
||||
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
|
||||
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
|
||||
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
|
||||
@@ -194,6 +195,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
e.Property(x => x.Arguments).HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
builder.Entity<AiEntryDraftRecord>(e =>
|
||||
{
|
||||
e.ToTable("AiEntryDrafts");
|
||||
e.HasIndex(x => new { x.UserId, x.ConversationId, x.EntryType, x.Status, x.ExpiresAt });
|
||||
e.Property(x => x.EntryType).HasMaxLength(32);
|
||||
e.Property(x => x.Status).HasMaxLength(32);
|
||||
e.Property(x => x.Payload).HasColumnType("jsonb");
|
||||
e.Property(x => x.MissingFields).HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
builder.Entity<ReportAnalysisTaskRecord>(e =>
|
||||
{
|
||||
e.ToTable("ReportAnalysisTasks");
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="AI\Prompts\**\*.md" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Health.Application.AI;
|
||||
using Health.Application.Reports;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Infrastructure.AI;
|
||||
@@ -191,20 +192,21 @@ public sealed class ReportAnalysisService(
|
||||
4. 不结合用户档案/病历做关联分析
|
||||
5. 建议就医评估异常指标
|
||||
6. 末尾提醒"以上为AI整理,不能替代医生诊断和治疗建议"
|
||||
7. 只使用固定医学来源目录中的 S-REPORT-01 和 S-REPORT-02 作为参考来源,不得编造其他来源
|
||||
|
||||
只返回整理文本,不要JSON格式。
|
||||
""";
|
||||
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "system", Content = "你是报告整理助手,不替代医生诊断。所有指标解读请用户咨询医生。" },
|
||||
new() { Role = "system", Content = "你是报告整理助手,不替代医生诊断。所有指标解读请用户咨询医生。\n\n" + MedicalCitationKnowledge.Prompt },
|
||||
new() { Role = "user", Content = prompt }
|
||||
};
|
||||
|
||||
var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct);
|
||||
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
|
||||
return !string.IsNullOrWhiteSpace(summary)
|
||||
? summary
|
||||
? $"{summary}\n\n{MedicalCitationKnowledge.FixedReportCitation.Trim()}"
|
||||
: throw new InvalidOperationException("AI 未返回有效解读内容");
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,11 +20,12 @@ public static class UserEndpoints
|
||||
var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
|
||||
var updated = await users.UpdateProfileAsync(
|
||||
GetUserId(http),
|
||||
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate),
|
||||
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate, req.AvatarUrl),
|
||||
ct);
|
||||
if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" });
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var profile = await users.GetProfileAsync(GetUserId(http), ct);
|
||||
return Results.Ok(new { code = 0, data = profile, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
|
||||
@@ -66,7 +67,7 @@ public static class UserEndpoints
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
|
||||
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate);
|
||||
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate, string? AvatarUrl);
|
||||
public sealed record UpdateArchiveRequest(
|
||||
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
||||
List<string>? Allergies, List<string>? DietRestrictions,
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
// 客户端或启动探针取消请求时,不再尝试写入错误响应。
|
||||
}
|
||||
catch (Health.Domain.ValidationException vex)
|
||||
{
|
||||
// 业务输入校验失败:返回 400,message 为我们自己产生的提示,可安全展示
|
||||
@@ -37,6 +41,7 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
||||
{
|
||||
// 生产环境不暴露内部异常详情
|
||||
_logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path);
|
||||
if (context.Response.HasStarted) return;
|
||||
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ builder.Services.AddSingleton<SmsService>();
|
||||
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
|
||||
builder.Services.AddSingleton<PromptManager>();
|
||||
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
||||
builder.Services.AddScoped<IAiEntryDraftStore, EfAiEntryDraftStore>();
|
||||
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
||||
builder.Services.AddScoped<IAdminService, AdminService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
@@ -165,8 +166,10 @@ builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||
});
|
||||
builder.Services.AddHttpClient<VisionClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? "");
|
||||
var baseUrl = builder.Configuration["VLM_BASE_URL"] ?? builder.Configuration["QWEN_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
var apiKey = builder.Configuration["VLM_API_KEY"] ?? builder.Configuration["QWEN_API_KEY"] ?? "";
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"JWT_AUDIENCE": "health-manager-app",
|
||||
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
||||
"DEEPSEEK_API_KEY": "sk-your-key-here",
|
||||
"DEEPSEEK_MODEL": "deepseek-chat",
|
||||
"DEEPSEEK_MODEL": "deepseek-v4-flash",
|
||||
"ENABLE_MEDICAL_RAG": false,
|
||||
"QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"QWEN_API_KEY": "sk-your-key-here",
|
||||
"QWEN_VISION_MODEL": "qwen-vl-max",
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
@@ -311,6 +371,62 @@ public sealed class ApplicationServiceTests
|
||||
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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user