From 70c1b3eae0a2d4fe196480fb424d4e964a0e6c50 Mon Sep 17 00:00:00 2001
From: MingNian <1281442923@qq.com>
Date: Wed, 29 Jul 2026 13:29:51 +0800
Subject: [PATCH] feat: enforce iOS AI safety policy and citations
---
.../AI/ClientPolicyProfile.cs | 30 +++
.../AI/MedicalCitationKnowledge.cs | 202 ++++++++++++++++++
.../HealthRecords/HealthRecordService.cs | 11 +
.../AI/AgentHandlers/report_agent_handler.cs | 25 ++-
.../AI/prompt_manager.cs | 64 ++++--
.../Reports/ReportAnalysisService.cs | 16 +-
.../Endpoints/ai_chat_endpoints.cs | 124 +++++++++--
backend/src/Health.WebApi/Program.cs | 3 +
.../Services/ClientPolicyContext.cs | 27 +++
health_app/ios/Runner/Info.plist | 4 -
health_app/lib/core/api_client.dart | 20 +-
health_app/lib/core/app_router.dart | 6 +-
.../home/widgets/chat_messages_view.dart | 15 +-
.../lib/pages/settings/settings_pages.dart | 13 +-
health_app/lib/utils/sse_handler.dart | 5 +-
health_app/lib/widgets/health_drawer.dart | 15 +-
16 files changed, 513 insertions(+), 67 deletions(-)
create mode 100644 backend/src/Health.Application/AI/ClientPolicyProfile.cs
create mode 100644 backend/src/Health.Application/AI/MedicalCitationKnowledge.cs
create mode 100644 backend/src/Health.WebApi/Services/ClientPolicyContext.cs
diff --git a/backend/src/Health.Application/AI/ClientPolicyProfile.cs b/backend/src/Health.Application/AI/ClientPolicyProfile.cs
new file mode 100644
index 0000000..3630203
--- /dev/null
+++ b/backend/src/Health.Application/AI/ClientPolicyProfile.cs
@@ -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; }
+}
diff --git a/backend/src/Health.Application/AI/MedicalCitationKnowledge.cs b/backend/src/Health.Application/AI/MedicalCitationKnowledge.cs
new file mode 100644
index 0000000..306e02b
--- /dev/null
+++ b/backend/src/Health.Application/AI/MedicalCitationKnowledge.cs
@@ -0,0 +1,202 @@
+namespace Health.Application.AI;
+
+///
+/// iOS 审核策略使用的固定医学来源目录,不依赖在线 RAG。
+///
+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();
+ 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 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;
+ }
+}
diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs
index 2ea2d00..c6974c3 100644
--- a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs
+++ b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs
@@ -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(
diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs
index 5a587f6..e23b527 100644
--- a/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs
+++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs
@@ -1,3 +1,5 @@
+using Health.Application.AI;
+
namespace Health.Infrastructure.AI.AgentHandlers;
///
@@ -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;
+ }
+ }
}
diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs
index e95f4cf..d6c217f 100644
--- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs
+++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs
@@ -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 intents)
+ public string ComposeForIntents(
+ IEnumerable 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,并使用带来源的医学回答规则。
+ """;
+
///
/// Compatibility entry for older clients that still address a specific agent.
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
///
- 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 sections) =>
diff --git a/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs
index e36549a..82f5bd9 100644
--- a/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs
+++ b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs
@@ -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 GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
+ private async Task GenerateSummaryAsync(
+ string indicatorsJson,
+ CancellationToken ct)
{
var prompt = $"""
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
@@ -197,14 +200,21 @@ public sealed class ReportAnalysisService(
var messages = new List
{
- 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 未返回有效解读内容");
}
diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs
index 8e0f3c1..cebf256 100644
--- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs
+++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs
@@ -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(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 GetToolsForIntents(IEnumerable intents)
+ private static List GetToolsForIntents(
+ IEnumerable intents,
+ ClientPolicyProfile profile)
{
var tools = new List();
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 GetToolsForAgent(AgentType agentType) => agentType switch
+ private static List 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 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 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))
diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs
index d9408d1..4bdb817 100644
--- a/backend/src/Health.WebApi/Program.cs
+++ b/backend/src/Health.WebApi/Program.cs
@@ -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();
builder.Services.AddSingleton();
builder.Services.AddHttpClient(client =>
{
diff --git a/backend/src/Health.WebApi/Services/ClientPolicyContext.cs b/backend/src/Health.WebApi/Services/ClientPolicyContext.cs
new file mode 100644
index 0000000..0cf518f
--- /dev/null
+++ b/backend/src/Health.WebApi/Services/ClientPolicyContext.cs
@@ -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;
+}
diff --git a/health_app/ios/Runner/Info.plist b/health_app/ios/Runner/Info.plist
index 6152c17..277c12a 100644
--- a/health_app/ios/Runner/Info.plist
+++ b/health_app/ios/Runner/Info.plist
@@ -26,10 +26,6 @@
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
- NSBluetoothAlwaysUsageDescription
- 需要使用蓝牙连接支持的血压计等健康设备,以同步用户主动测量的健康数据
- NSBluetoothPeripheralUsageDescription
- 需要使用蓝牙连接支持的血压计等健康设备
NSCameraUsageDescription
需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录
NSMicrophoneUsageDescription
diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart
index 0fc6893..2b8d315 100644
--- a/health_app/lib/core/api_client.dart
+++ b/health_app/lib/core/api_client.dart
@@ -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));
diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart
index 82ba55f..a602569 100644
--- a/health_app/lib/core/app_router.dart
+++ b/health_app/lib/core/app_router.dart
@@ -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':
diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart
index bb12c9e..b41c0c9 100644
--- a/health_app/lib/pages/home/widgets/chat_messages_view.dart
+++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart
@@ -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 = >{
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(
diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart
index 2c83aa2..b5760c0 100644
--- a/health_app/lib/pages/settings/settings_pages.dart
+++ b/health_app/lib/pages/settings/settings_pages.dart
@@ -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: '消息通知',
diff --git a/health_app/lib/utils/sse_handler.dart b/health_app/lib/utils/sse_handler.dart
index 75b9c6e..4950dca 100644
--- a/health_app/lib/utils/sse_handler.dart
+++ b/health_app/lib/utils/sse_handler.dart
@@ -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,
+ },
),
);
diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart
index 31bb7a3..8c89400 100644
--- a/health_app/lib/widgets/health_drawer.dart
+++ b/health_app/lib/widgets/health_drawer.dart
@@ -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(