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(