feat: enforce iOS AI safety policy and citations
This commit is contained in:
30
backend/src/Health.Application/AI/ClientPolicyProfile.cs
Normal file
30
backend/src/Health.Application/AI/ClientPolicyProfile.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public enum ClientPolicyProfile
|
||||
{
|
||||
Legacy,
|
||||
AndroidStandard,
|
||||
IosMedicalCitations,
|
||||
}
|
||||
|
||||
public static class ClientPolicyProfiles
|
||||
{
|
||||
public const string HeaderName = "X-App-Policy";
|
||||
public const string AndroidStandardKey = "android-standard";
|
||||
public const string IosMedicalCitationsKey = "ios-medical-citations";
|
||||
public const string LegacyKey = "legacy";
|
||||
|
||||
public static string ToKey(this ClientPolicyProfile profile) => profile switch
|
||||
{
|
||||
ClientPolicyProfile.AndroidStandard => AndroidStandardKey,
|
||||
ClientPolicyProfile.IosMedicalCitations => IosMedicalCitationsKey,
|
||||
_ => LegacyKey,
|
||||
};
|
||||
}
|
||||
|
||||
public interface IClientPolicyContext
|
||||
{
|
||||
ClientPolicyProfile Profile { get; }
|
||||
string ProfileKey { get; }
|
||||
bool IsIos { get; }
|
||||
}
|
||||
202
backend/src/Health.Application/AI/MedicalCitationKnowledge.cs
Normal file
202
backend/src/Health.Application/AI/MedicalCitationKnowledge.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
namespace Health.Application.AI;
|
||||
|
||||
/// <summary>
|
||||
/// iOS 审核策略使用的固定医学来源目录,不依赖在线 RAG。
|
||||
/// </summary>
|
||||
public static class MedicalCitationKnowledge
|
||||
{
|
||||
private static readonly string[] ApprovedSourceUrls =
|
||||
[
|
||||
"https://pubmed.ncbi.nlm.nih.gov/39210715/",
|
||||
"https://pubmed.ncbi.nlm.nih.gov/39651981/",
|
||||
"https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse",
|
||||
"https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics",
|
||||
"https://pubmed.ncbi.nlm.nih.gov/37128940/",
|
||||
"https://pubmed.ncbi.nlm.nih.gov/40504596/",
|
||||
"https://pubmed.ncbi.nlm.nih.gov/38059362/",
|
||||
"https://pubmed.ncbi.nlm.nih.gov/39315436/",
|
||||
"https://pubmed.ncbi.nlm.nih.gov/39480983/",
|
||||
"https://pubmed.ncbi.nlm.nih.gov/40268322/",
|
||||
"https://pubmed.ncbi.nlm.nih.gov/29729142/",
|
||||
"https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines",
|
||||
"https://medlineplus.gov/healthtopics.html",
|
||||
];
|
||||
|
||||
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
|
||||
|
||||
[S-GEN-01] Health Topics, MedlinePlus, U.S. National Library of Medicine
|
||||
适用:固定目录没有更具体来源时的一般症状、疾病、检查、治疗和健康信息入口;不得据此生成诊断、处方或个体化治疗方案。
|
||||
https://medlineplus.gov/healthtopics.html
|
||||
|
||||
来源使用边界:
|
||||
- 血压:单次记录不能单独诊断高血压;应结合多次记录、测量条件、症状和医生评估;不得建议自行加药、减药或停药。
|
||||
- 血糖:必须关注空腹/餐后等测量时段和既往用药;不能把一个统一目标套用给所有人;不得据此诊断糖尿病或指导自行调药。
|
||||
- 心率:静息心率的客观范围不能替代诊断;应结合是否安静、是否有不适和变化趋势说明。
|
||||
- 血氧:读数是估计值,可能受手部温度、活动、循环、皮肤因素和设备影响;应结合症状和复测结果,不得仅凭单个数值诊断或指导吸氧/调药。
|
||||
- 饮食:图片不能精确确定重量、份量、烹调油和调味料;盐、糖、脂肪和热量只能作一般估算;不得根据疾病档案生成个体化饮食处方。
|
||||
- 运动:只能提供一般运动计划记录和安全提醒;不得针对心血管疾病制定运动处方;有胸痛、晕厥或明显呼吸困难时应先就医评估。
|
||||
- 用药:只能记录计划、查询任务和提醒;开始、停止、更换或调整药物应由医生或药师决定。
|
||||
- 报告:优先使用报告本身提供的参考范围;标记高/低不等于诊断;不同实验室参考范围不能直接互换;应由医生结合症状和病史解释。
|
||||
- 危险症状:胸部不适、呼吸困难、单侧无力、语言障碍、意识异常等应立即联系当地急救服务,不要等待 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 FixedBloodPressureCitation = """
|
||||
参考来源:
|
||||
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
|
||||
https://pubmed.ncbi.nlm.nih.gov/39210715/
|
||||
""";
|
||||
|
||||
public const string FixedGlucoseCitation = """
|
||||
参考来源:
|
||||
[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes—2025
|
||||
https://pubmed.ncbi.nlm.nih.gov/39651981/
|
||||
""";
|
||||
|
||||
public const string FixedHeartRateCitation = """
|
||||
参考来源:
|
||||
[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
|
||||
""";
|
||||
|
||||
public const string FixedOxygenCitation = """
|
||||
参考来源:
|
||||
[S-OXY-01] Pulse Oximeter Basics, U.S. Food and Drug Administration
|
||||
https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
|
||||
""";
|
||||
|
||||
public const string FixedExerciseCitation = """
|
||||
参考来源:
|
||||
[S-EX-01] Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
|
||||
https://pubmed.ncbi.nlm.nih.gov/38059362/
|
||||
""";
|
||||
|
||||
public const string FixedMedicationCitation = """
|
||||
参考来源:
|
||||
[S-MED-01] Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
|
||||
https://pubmed.ncbi.nlm.nih.gov/39480983/
|
||||
""";
|
||||
|
||||
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
|
||||
""";
|
||||
|
||||
public const string GeneralHealthCitation = """
|
||||
参考来源:
|
||||
[S-GEN-01] Health Topics, MedlinePlus, U.S. National Library of Medicine
|
||||
https://medlineplus.gov/healthtopics.html
|
||||
""";
|
||||
|
||||
public static string SelectFallbackCitation(string context)
|
||||
{
|
||||
var citations = new List<string>();
|
||||
AddWhen(citations, context, FixedBloodPressureCitation, "血压", "收缩压", "舒张压", "高压", "低压");
|
||||
AddWhen(citations, context, FixedGlucoseCitation, "血糖", "葡萄糖", "空腹", "餐后", "糖尿病", "低血糖");
|
||||
AddWhen(citations, context, FixedHeartRateCitation, "心率", "脉搏", "心跳");
|
||||
AddWhen(citations, context, FixedOxygenCitation, "血氧", "氧饱和度", "spo2");
|
||||
AddWhen(citations, context, FixedDietCitation, "饮食", "营养", "食物", "吃什么", "忌口", "热量");
|
||||
AddWhen(citations, context, FixedExerciseCitation, "运动", "锻炼", "散步", "跑步", "力量训练", "康复训练");
|
||||
AddWhen(citations, context, FixedMedicationCitation, "用药", "药物", "服药", "剂量", "药师", "停药", "换药");
|
||||
AddWhen(citations, context, FixedReportCitation, "报告", "检验", "检查", "化验", "参考范围", "指标");
|
||||
AddWhen(citations, context, EmergencyCitation, "急救", "胸痛", "呼吸困难", "意识异常", "单侧无力", "言语障碍");
|
||||
|
||||
if (citations.Count == 0)
|
||||
citations.Add(GeneralHealthCitation);
|
||||
|
||||
return "参考来源:\n" + string.Join(
|
||||
"\n",
|
||||
citations
|
||||
.Select(RemoveHeading)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(value => value.Trim()));
|
||||
}
|
||||
|
||||
public static bool ContainsApprovedCitation(string response) =>
|
||||
response.Contains("参考来源", StringComparison.Ordinal) &&
|
||||
ApprovedSourceUrls.Any(url =>
|
||||
response.Contains(url, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static void AddWhen(
|
||||
ICollection<string> citations,
|
||||
string context,
|
||||
string citation,
|
||||
params string[] keywords)
|
||||
{
|
||||
if (keywords.Any(keyword =>
|
||||
context.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
|
||||
citations.Add(citation);
|
||||
}
|
||||
|
||||
private static string RemoveHeading(string citation)
|
||||
{
|
||||
const string heading = "参考来源:";
|
||||
var value = citation.Trim();
|
||||
return value.StartsWith(heading, StringComparison.Ordinal)
|
||||
? value[heading.Length..].TrimStart()
|
||||
: value;
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,17 @@ 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(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Health.Application.AI;
|
||||
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
/// <summary>
|
||||
@@ -34,16 +36,35 @@ public static class ReportAgentHandler
|
||||
var response = await visionClient.VisionAsync(prompt, [imageUrl], userText: "请分析这份检查报告");
|
||||
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||||
|
||||
// 保存到报告记录
|
||||
var summary = ExtractSummary(content);
|
||||
|
||||
// 保存到报告记录:指标 JSON 保持可解析,患者可见摘要始终附带来源。
|
||||
var report = await db.Reports
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.FirstOrDefaultAsync(r => r.UserId == userId && r.Status == ReportStatus.PendingDoctor);
|
||||
if (report != null)
|
||||
{
|
||||
report.AiSummary = content;
|
||||
report.AiSummary = $"{summary}\n\n{MedicalCitationKnowledge.FixedReportCitation.Trim()}";
|
||||
report.AiIndicators = content;
|
||||
}
|
||||
|
||||
return new { success = true, analysis = content };
|
||||
}
|
||||
|
||||
private static string ExtractSummary(string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(content);
|
||||
return json.RootElement.TryGetProperty("summary", out var summary) &&
|
||||
summary.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(summary.GetString())
|
||||
? summary.GetString()!
|
||||
: content;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Reflection;
|
||||
using Health.Application.AI;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
@@ -20,7 +21,9 @@ public sealed class PromptManager
|
||||
|
||||
public string GetIntentRouterPrompt() => Read("router/intent_router.md");
|
||||
|
||||
public string ComposeForIntents(IEnumerable<string> intents)
|
||||
public string ComposeForIntents(
|
||||
IEnumerable<string> intents,
|
||||
ClientPolicyProfile profile = ClientPolicyProfile.AndroidStandard)
|
||||
{
|
||||
var normalized = intents
|
||||
.Select(NormalizeIntent)
|
||||
@@ -46,28 +49,61 @@ public sealed class PromptManager
|
||||
});
|
||||
}
|
||||
|
||||
if (normalized.Contains("medical_consultation") ||
|
||||
normalized.Contains("report_analysis") ||
|
||||
normalized.Contains("diet_analysis"))
|
||||
if (profile == ClientPolicyProfile.AndroidStandard &&
|
||||
(normalized.Contains("medical_consultation") ||
|
||||
normalized.Contains("report_analysis") ||
|
||||
normalized.Contains("diet_analysis")))
|
||||
{
|
||||
sections.Add("rag/retrieval_rules.md");
|
||||
}
|
||||
|
||||
return Compose(sections);
|
||||
var composed = Compose(sections);
|
||||
if (profile == ClientPolicyProfile.IosMedicalCitations)
|
||||
{
|
||||
composed += "\n\n" + CitationRules + "\n\n" + MedicalCitationKnowledge.Prompt;
|
||||
}
|
||||
|
||||
return profile == ClientPolicyProfile.IosMedicalCitations
|
||||
? composed + "\n\n" + IosScopeRules
|
||||
: composed;
|
||||
}
|
||||
|
||||
private const string CitationRules = """
|
||||
医疗信息来源引用(必须遵守):
|
||||
- 当回复包含医疗事实、数值解释、饮食/运动/用药相关信息、报告解释、危险症状或就医建议时,必须在回复末尾添加“参考来源”。
|
||||
- 只能引用本轮系统消息中“固定医学参考来源目录”里实际提供的来源,不得编造来源、PMID、DOI 或 URL。
|
||||
- 引用格式必须是:
|
||||
参考来源:
|
||||
[来源编号] 来源标题
|
||||
完整 URL
|
||||
- 引用必须与本次回答的内容直接相关;不要堆砌无关来源。
|
||||
- 如果固定医学参考来源目录没有提供相关来源,只能说明资料不足并建议咨询医生/药师,不得凭常识补充具体医疗建议。
|
||||
- 纯业务操作回复(例如查询计划、创建待确认记录、说明上传状态)不需要添加医学来源。
|
||||
""";
|
||||
|
||||
private const string IosScopeRules = """
|
||||
当前 iOS 客户端范围:
|
||||
- 当前 iOS 客户端不包含蓝牙设备页、设备绑定、血压仪连接或自动设备同步。
|
||||
- 禁止生成 app://device 链接,禁止引导用户使用蓝牙设备功能。
|
||||
- 健康数据均按用户手动输入或 AI 待确认记录处理。
|
||||
- 当前 iOS 客户端不调用在线 search_medical_knowledge;医疗回答只能使用本轮提供的固定医学来源目录。
|
||||
- health_entry、medication_entry、exercise_entry、personal_query 和 general_chat 只处理录入、查询或应用操作,不主动给出医疗建议;用户明确询问是否正常、原因、风险或如何改善时,必须进入 medical_consultation,并使用带来源的医学回答规则。
|
||||
""";
|
||||
|
||||
/// <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
|
||||
public string GetSystemPrompt(
|
||||
AgentType agentType,
|
||||
ClientPolicyProfile profile = ClientPolicyProfile.AndroidStandard) => 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.Health => ComposeForIntents(["health_entry", "personal_query"], profile),
|
||||
AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"], profile),
|
||||
AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"], profile),
|
||||
AgentType.Consultation => ComposeForIntents(["medical_consultation"], profile),
|
||||
AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"], profile),
|
||||
AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"], profile),
|
||||
AgentType.Unified => ComposeForIntents([
|
||||
"health_entry",
|
||||
"medication_entry",
|
||||
@@ -77,8 +113,8 @@ public sealed class PromptManager
|
||||
"report_analysis",
|
||||
"diet_analysis",
|
||||
"general_chat",
|
||||
]),
|
||||
_ => ComposeForIntents(["general_chat", "medical_consultation"]),
|
||||
], profile),
|
||||
_ => ComposeForIntents(["general_chat", "medical_consultation"], profile),
|
||||
};
|
||||
|
||||
private static string Compose(IEnumerable<string> sections) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Health.Application.Reports;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Application.AI;
|
||||
using Health.Infrastructure.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using UglyToad.PdfPig;
|
||||
@@ -177,7 +178,9 @@ public sealed class ReportAnalysisService(
|
||||
return text.Length > MaxPdfChars ? text[..MaxPdfChars] : text;
|
||||
}
|
||||
|
||||
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
|
||||
private async Task<string> GenerateSummaryAsync(
|
||||
string indicatorsJson,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var prompt = $"""
|
||||
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
|
||||
@@ -197,14 +200,21 @@ public sealed class ReportAnalysisService(
|
||||
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" },
|
||||
new()
|
||||
{
|
||||
Role = "system",
|
||||
Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。"
|
||||
+ "\n\n所有医学解释和建议必须使用下面固定来源,并在回答末尾保留可点击来源链接。"
|
||||
+ "\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 未返回有效解读内容");
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ public static class AiChatEndpoints
|
||||
IAiConversationService conversations,
|
||||
IAttachmentContextBuilder attachments,
|
||||
IPatientContextService patientContexts,
|
||||
IClientPolicyContext clientPolicy,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
@@ -122,6 +123,8 @@ public static class AiChatEndpoints
|
||||
|
||||
以上为 AI 安全提醒,不能替代医生诊断和治疗建议。
|
||||
""";
|
||||
if (clientPolicy.IsIos)
|
||||
urgentResponse += "\n\n" + MedicalCitationKnowledge.EmergencyCitation.Trim();
|
||||
|
||||
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
|
||||
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
|
||||
@@ -178,8 +181,8 @@ public static class AiChatEndpoints
|
||||
// Only compose the modules selected for this turn. This keeps entry,
|
||||
// query and consultation rules from competing in one giant prompt.
|
||||
var systemPrompt = parsedType == AgentType.Unified
|
||||
? promptManager.ComposeForIntents(intentRoute.Intents)
|
||||
: promptManager.GetSystemPrompt(parsedType);
|
||||
? promptManager.ComposeForIntents(intentRoute.Intents, clientPolicy.Profile)
|
||||
: promptManager.GetSystemPrompt(parsedType, clientPolicy.Profile);
|
||||
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
|
||||
|
||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||
@@ -245,8 +248,8 @@ public static class AiChatEndpoints
|
||||
|
||||
// Tool Calling 循环
|
||||
var tools = parsedType == AgentType.Unified
|
||||
? GetToolsForIntents(intentRoute.Intents)
|
||||
: GetToolsForAgent(parsedType);
|
||||
? GetToolsForIntents(intentRoute.Intents, clientPolicy.Profile)
|
||||
: GetToolsForAgent(parsedType, clientPolicy.Profile);
|
||||
var requiredEntryTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (intentRoute.DraftAction != "cancel")
|
||||
{
|
||||
@@ -513,6 +516,20 @@ public static class AiChatEndpoints
|
||||
messages,
|
||||
llmClient,
|
||||
ct);
|
||||
|
||||
var citationSuffix = GetRequiredIosCitationSuffix(
|
||||
clientPolicy.IsIos,
|
||||
message,
|
||||
fullResponse,
|
||||
intentRoute.Intents);
|
||||
if (!string.IsNullOrWhiteSpace(citationSuffix))
|
||||
{
|
||||
var streamedSuffix = $"\n\n{citationSuffix}";
|
||||
fullResponse += streamedSuffix;
|
||||
if (answerStreamed)
|
||||
await StreamAnswerTextAsync(http, streamedSuffix, ct);
|
||||
}
|
||||
|
||||
if (!answerStreamed)
|
||||
{
|
||||
await StreamAnswerTextAsync(http, fullResponse, ct);
|
||||
@@ -602,6 +619,7 @@ public static class AiChatEndpoints
|
||||
HttpContext http,
|
||||
DeepSeekClient llmClient,
|
||||
IPatientContextService patientContexts,
|
||||
IClientPolicyContext clientPolicy,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
@@ -635,6 +653,8 @@ public static class AiChatEndpoints
|
||||
var commentary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(commentary))
|
||||
return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" });
|
||||
if (clientPolicy.IsIos)
|
||||
commentary = $"{commentary}\n\n{MedicalCitationKnowledge.FixedDietCitation.Trim()}";
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null });
|
||||
}).RequireAuthorization();
|
||||
@@ -847,7 +867,9 @@ public static class AiChatEndpoints
|
||||
_ => AiIntentRoute.Compatibility,
|
||||
};
|
||||
|
||||
private static List<ToolDefinition> GetToolsForIntents(IEnumerable<string> intents)
|
||||
private static List<ToolDefinition> GetToolsForIntents(
|
||||
IEnumerable<string> intents,
|
||||
ClientPolicyProfile profile)
|
||||
{
|
||||
var tools = new List<ToolDefinition>();
|
||||
foreach (var intent in intents.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
@@ -879,21 +901,24 @@ public static class AiChatEndpoints
|
||||
tools.AddRange([
|
||||
CommonAgentHandler.QueryHealthRecordsTool,
|
||||
CommonAgentHandler.CheckArchiveTool,
|
||||
CommonAgentHandler.SearchMedicalKnowledgeTool,
|
||||
]);
|
||||
if (profile == ClientPolicyProfile.AndroidStandard)
|
||||
tools.Add(CommonAgentHandler.SearchMedicalKnowledgeTool);
|
||||
break;
|
||||
case "report_analysis":
|
||||
tools.AddRange([
|
||||
ReportAgentHandler.AnalyzeReportTool,
|
||||
CommonAgentHandler.QueryHealthRecordsTool,
|
||||
CommonAgentHandler.SearchMedicalKnowledgeTool,
|
||||
]);
|
||||
if (profile == ClientPolicyProfile.AndroidStandard)
|
||||
tools.Add(CommonAgentHandler.SearchMedicalKnowledgeTool);
|
||||
break;
|
||||
case "diet_analysis":
|
||||
tools.AddRange([
|
||||
CommonAgentHandler.CheckArchiveTool,
|
||||
CommonAgentHandler.SearchMedicalKnowledgeTool,
|
||||
]);
|
||||
if (profile == ClientPolicyProfile.AndroidStandard)
|
||||
tools.Add(CommonAgentHandler.SearchMedicalKnowledgeTool);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -904,21 +929,25 @@ public static class AiChatEndpoints
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch
|
||||
private static List<ToolDefinition> GetToolsForAgent(
|
||||
AgentType agentType,
|
||||
ClientPolicyProfile profile)
|
||||
{
|
||||
AgentType.Health => [.. HealthDataAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Medication => [.. MedicationAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Diet => [.. DietAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Consultation => [.. ConsultationAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Report => [.. ReportAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Exercise => [.. ExerciseAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Default => [
|
||||
List<ToolDefinition> tools = agentType switch
|
||||
{
|
||||
AgentType.Health => [.. HealthDataAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Medication => [.. MedicationAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Diet => [.. DietAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Consultation => [.. ConsultationAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Report => [.. ReportAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Exercise => [.. ExerciseAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
AgentType.Default => [
|
||||
HealthDataAgentHandler.RecordHealthDataBatchTool,
|
||||
CommonAgentHandler.QueryHealthRecordsTool,
|
||||
CommonAgentHandler.CheckArchiveTool,
|
||||
CommonAgentHandler.SearchMedicalKnowledgeTool,
|
||||
],
|
||||
AgentType.Unified => [
|
||||
],
|
||||
AgentType.Unified => [
|
||||
HealthDataAgentHandler.RecordHealthDataBatchTool,
|
||||
CommonAgentHandler.QueryHealthRecordsTool,
|
||||
MedicationAgentHandler.ManageMedicationTool,
|
||||
@@ -929,9 +958,18 @@ public static class AiChatEndpoints
|
||||
PatientReadAgentHandler.QueryReportsTool,
|
||||
PatientReadAgentHandler.QueryNotificationsTool,
|
||||
CommonAgentHandler.SearchMedicalKnowledgeTool,
|
||||
],
|
||||
_ => [.. CommonAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
};
|
||||
],
|
||||
_ => [.. CommonAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
||||
};
|
||||
return profile == ClientPolicyProfile.IosMedicalCitations
|
||||
? tools.Where(tool =>
|
||||
!string.Equals(
|
||||
tool.Function.Name,
|
||||
CommonAgentHandler.SearchMedicalKnowledgeTool.Function.Name,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.ToList()
|
||||
: tools;
|
||||
}
|
||||
|
||||
private static bool IsPersonalQueryTool(string toolName) => toolName is
|
||||
"query_health_records" or
|
||||
@@ -943,6 +981,50 @@ public static class AiChatEndpoints
|
||||
"manage_medication" or
|
||||
"manage_exercise";
|
||||
|
||||
private static string GetRequiredIosCitationSuffix(
|
||||
bool isIos,
|
||||
string userMessage,
|
||||
string assistantResponse,
|
||||
IEnumerable<string> intents)
|
||||
{
|
||||
if (!isIos ||
|
||||
string.IsNullOrWhiteSpace(assistantResponse) ||
|
||||
MedicalCitationKnowledge.ContainsApprovedCitation(assistantResponse))
|
||||
return "";
|
||||
|
||||
var intentSet = intents.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var medicalIntent =
|
||||
intentSet.Contains("medical_consultation") ||
|
||||
intentSet.Contains("report_analysis") ||
|
||||
intentSet.Contains("diet_analysis");
|
||||
var combined = $"{userMessage}\n{assistantResponse}";
|
||||
var hasMedicalTopic = ContainsAny(
|
||||
combined,
|
||||
"健康", "症状", "疾病", "疼", "痛", "发烧", "咳嗽", "头晕", "恶心",
|
||||
"血压", "血糖", "心率", "脉搏", "血氧", "饮食", "营养", "运动",
|
||||
"锻炼", "药", "报告", "检验", "检查", "化验", "指标", "就医", "医生");
|
||||
var userAsksForMedicalGuidance = ContainsAny(
|
||||
userMessage,
|
||||
"正常吗", "是否正常", "怎么办", "怎么改善", "如何改善", "什么原因",
|
||||
"为什么", "风险", "有什么影响", "注意什么", "需要就医", "要去医院",
|
||||
"能不能吃", "可以吃吗", "能不能运动", "可以运动吗", "建议",
|
||||
"高不高", "低不低", "严重吗", "评价", "分析", "解读");
|
||||
var responseGivesMedicalGuidance = ContainsAny(
|
||||
assistantResponse,
|
||||
"可能原因", "健康风险", "参考范围", "建议就医", "建议复测", "建议咨询医生",
|
||||
"建议咨询药师", "生活方式", "饮食建议", "运动建议", "用药建议", "及时就医",
|
||||
"立即就医", "应避免", "需要警惕", "不能替代医生", "建议", "应该", "最好",
|
||||
"可以尝试", "不建议", "避免", "保持", "控制", "复测", "观察", "咨询医生",
|
||||
"正常", "异常", "偏高", "偏低", "风险");
|
||||
|
||||
if (!medicalIntent &&
|
||||
!(hasMedicalTopic &&
|
||||
(userAsksForMedicalGuidance || responseGivesMedicalGuidance)))
|
||||
return "";
|
||||
|
||||
return MedicalCitationKnowledge.SelectFallbackCitation(combined);
|
||||
}
|
||||
|
||||
private static bool IsPersonalQueryToolCall(string toolName, string arguments)
|
||||
{
|
||||
if (!IsPersonalQueryTool(toolName))
|
||||
|
||||
@@ -33,6 +33,7 @@ using Health.WebApi.BackgroundServices;
|
||||
using Health.WebApi.Converters;
|
||||
using Health.WebApi.Endpoints;
|
||||
using Health.WebApi.Middleware;
|
||||
using Health.WebApi.Services;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -107,6 +108,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// ---- 业务服务 ----
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<IClientPolicyContext, ClientPolicyContext>();
|
||||
builder.Services.AddSingleton<JwtProvider>();
|
||||
builder.Services.AddHttpClient<BceSmsClient>(client =>
|
||||
{
|
||||
|
||||
27
backend/src/Health.WebApi/Services/ClientPolicyContext.cs
Normal file
27
backend/src/Health.WebApi/Services/ClientPolicyContext.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Health.Application.AI;
|
||||
|
||||
namespace Health.WebApi.Services;
|
||||
|
||||
public sealed class ClientPolicyContext(IHttpContextAccessor accessor) : IClientPolicyContext
|
||||
{
|
||||
private readonly IHttpContextAccessor _accessor = accessor;
|
||||
|
||||
public ClientPolicyProfile Profile
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = _accessor.HttpContext?.Request.Headers[ClientPolicyProfiles.HeaderName]
|
||||
.FirstOrDefault()
|
||||
?.Trim();
|
||||
return string.Equals(
|
||||
value,
|
||||
ClientPolicyProfiles.IosMedicalCitationsKey,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? ClientPolicyProfile.IosMedicalCitations
|
||||
: ClientPolicyProfile.AndroidStandard;
|
||||
}
|
||||
}
|
||||
|
||||
public string ProfileKey => Profile.ToKey();
|
||||
public bool IsIos => Profile == ClientPolicyProfile.IosMedicalCitations;
|
||||
}
|
||||
@@ -26,10 +26,6 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>需要使用蓝牙连接支持的血压计等健康设备,以同步用户主动测量的健康数据</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>需要使用蓝牙连接支持的血压计等健康设备</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
||||
@@ -9,9 +9,22 @@ const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: kReleaseMode
|
||||
? 'https://erpapi.datalumina.cn/xiaomai'
|
||||
: 'http://10.4.225.209:5000',
|
||||
: 'http://10.4.232.91:5000',
|
||||
);
|
||||
|
||||
const String _configuredAppPolicy = String.fromEnvironment(
|
||||
'APP_POLICY_PROFILE',
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
String get appPolicyProfile {
|
||||
if (_configuredAppPolicy == 'android-standard' ||
|
||||
_configuredAppPolicy == 'ios-medical-citations') {
|
||||
return _configuredAppPolicy;
|
||||
}
|
||||
return Platform.isIOS ? 'ios-medical-citations' : 'android-standard';
|
||||
}
|
||||
|
||||
class ApiException implements Exception {
|
||||
final int? code;
|
||||
final String message;
|
||||
@@ -56,7 +69,10 @@ class ApiClient {
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-App-Policy': appPolicyProfile,
|
||||
},
|
||||
),
|
||||
) {
|
||||
_dio.interceptors.add(_AuthInterceptor(this));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'navigation_provider.dart';
|
||||
@@ -120,9 +122,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
case 'doctorFollowUpEdit':
|
||||
return DoctorFollowUpEditPage(id: params['id']);
|
||||
case 'devices':
|
||||
return const DeviceManagementPage();
|
||||
return Platform.isIOS ? const HomePage() : const DeviceManagementPage();
|
||||
case 'deviceScan':
|
||||
return const DeviceScanPage();
|
||||
return Platform.isIOS ? const HomePage() : const DeviceScanPage();
|
||||
case 'healthArchive':
|
||||
return const HealthArchivePage();
|
||||
case 'followups':
|
||||
|
||||
@@ -1296,7 +1296,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
pushRoute(ref, 'reports');
|
||||
break;
|
||||
case 'device':
|
||||
pushRoute(ref, 'devices');
|
||||
if (!Platform.isIOS) pushRoute(ref, 'devices');
|
||||
break;
|
||||
default:
|
||||
if (uri.host.isNotEmpty) pushRoute(ref, uri.host);
|
||||
@@ -2199,12 +2199,13 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
||||
isWide: true,
|
||||
route: 'trend',
|
||||
),
|
||||
_AgentAction(
|
||||
label: '蓝牙录入',
|
||||
icon: Icons.bluetooth,
|
||||
isWide: true,
|
||||
route: 'devices',
|
||||
),
|
||||
if (!Platform.isIOS)
|
||||
_AgentAction(
|
||||
label: '蓝牙录入',
|
||||
icon: Icons.bluetooth,
|
||||
isWide: true,
|
||||
route: 'devices',
|
||||
),
|
||||
],
|
||||
ActiveAgent.diet: [
|
||||
_AgentAction(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
@@ -49,11 +51,12 @@ class SettingsPage extends ConsumerWidget {
|
||||
title: '长辈模式',
|
||||
onTap: () => pushRoute(ref, 'elderMode'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bluetooth,
|
||||
title: '蓝牙设备',
|
||||
onTap: () => pushRoute(ref, 'devices'),
|
||||
),
|
||||
if (!Platform.isIOS)
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bluetooth,
|
||||
title: '蓝牙设备',
|
||||
onTap: () => pushRoute(ref, 'devices'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bell,
|
||||
title: '消息通知',
|
||||
|
||||
@@ -63,7 +63,10 @@ class SseHandler {
|
||||
cancelToken: cancelToken,
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'X-App-Policy': appPolicyProfile,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
@@ -431,12 +433,13 @@ class _NavigationSection extends StatelessWidget {
|
||||
route: 'exercisePlan',
|
||||
colors: AppColors.exerciseGradient.colors,
|
||||
),
|
||||
_NavItem(
|
||||
icon: LucideIcons.bluetooth,
|
||||
title: '设备',
|
||||
route: 'devices',
|
||||
colors: AppColors.deviceGradient.colors,
|
||||
),
|
||||
if (!Platform.isIOS)
|
||||
_NavItem(
|
||||
icon: LucideIcons.bluetooth,
|
||||
title: '设备',
|
||||
route: 'devices',
|
||||
colors: AppColors.deviceGradient.colors,
|
||||
),
|
||||
];
|
||||
|
||||
return _LightSection(
|
||||
|
||||
Reference in New Issue
Block a user