feat: enforce iOS AI safety policy and citations

This commit is contained in:
MingNian
2026-07-29 13:29:51 +08:00
parent b4bc15b9b9
commit 70c1b3eae0
16 changed files with 513 additions and 67 deletions

View 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; }
}

View 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 = """
PMIDDOI 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 Diabetes2025
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 Diabetes2025
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;
}
}

View File

@@ -188,6 +188,17 @@ public sealed class HealthRecordService(
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "") _ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "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 window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}"); var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
await _notifications.EnqueueAsync( await _notifications.EnqueueAsync(

View File

@@ -1,3 +1,5 @@
using Health.Application.AI;
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary> /// <summary>
@@ -34,16 +36,35 @@ public static class ReportAgentHandler
var response = await visionClient.VisionAsync(prompt, [imageUrl], userText: "请分析这份检查报告"); var response = await visionClient.VisionAsync(prompt, [imageUrl], userText: "请分析这份检查报告");
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}"; var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
// 保存到报告记录 var summary = ExtractSummary(content);
// 保存到报告记录:指标 JSON 保持可解析,患者可见摘要始终附带来源。
var report = await db.Reports var report = await db.Reports
.OrderByDescending(r => r.CreatedAt) .OrderByDescending(r => r.CreatedAt)
.FirstOrDefaultAsync(r => r.UserId == userId && r.Status == ReportStatus.PendingDoctor); .FirstOrDefaultAsync(r => r.UserId == userId && r.Status == ReportStatus.PendingDoctor);
if (report != null) if (report != null)
{ {
report.AiSummary = content; report.AiSummary = $"{summary}\n\n{MedicalCitationKnowledge.FixedReportCitation.Trim()}";
report.AiIndicators = content; report.AiIndicators = content;
} }
return new { success = true, analysis = 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;
}
}
} }

View File

@@ -1,4 +1,5 @@
using System.Reflection; using System.Reflection;
using Health.Application.AI;
namespace Health.Infrastructure.AI; namespace Health.Infrastructure.AI;
@@ -20,7 +21,9 @@ public sealed class PromptManager
public string GetIntentRouterPrompt() => Read("router/intent_router.md"); 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 var normalized = intents
.Select(NormalizeIntent) .Select(NormalizeIntent)
@@ -46,28 +49,61 @@ public sealed class PromptManager
}); });
} }
if (normalized.Contains("medical_consultation") || if (profile == ClientPolicyProfile.AndroidStandard &&
(normalized.Contains("medical_consultation") ||
normalized.Contains("report_analysis") || normalized.Contains("report_analysis") ||
normalized.Contains("diet_analysis")) normalized.Contains("diet_analysis")))
{ {
sections.Add("rag/retrieval_rules.md"); 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 = """
- //
- PMIDDOI URL
-
[来源编号]
URL
-
- /
-
""";
private const string IosScopeRules = """
iOS
- iOS
- app://device 链接,禁止引导用户使用蓝牙设备功能。
- AI
- iOS 线 search_medical_knowledge使
- health_entrymedication_entryexercise_entrypersonal_query general_chat medical_consultation使
""";
/// <summary> /// <summary>
/// Compatibility entry for older clients that still address a specific agent. /// Compatibility entry for older clients that still address a specific agent.
/// The main Flutter app uses Unified and is routed through ComposeForIntents. /// The main Flutter app uses Unified and is routed through ComposeForIntents.
/// </summary> /// </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.Health => ComposeForIntents(["health_entry", "personal_query"], profile),
AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]), AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"], profile),
AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]), AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"], profile),
AgentType.Consultation => ComposeForIntents(["medical_consultation"]), AgentType.Consultation => ComposeForIntents(["medical_consultation"], profile),
AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]), AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"], profile),
AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]), AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"], profile),
AgentType.Unified => ComposeForIntents([ AgentType.Unified => ComposeForIntents([
"health_entry", "health_entry",
"medication_entry", "medication_entry",
@@ -77,8 +113,8 @@ public sealed class PromptManager
"report_analysis", "report_analysis",
"diet_analysis", "diet_analysis",
"general_chat", "general_chat",
]), ], profile),
_ => ComposeForIntents(["general_chat", "medical_consultation"]), _ => ComposeForIntents(["general_chat", "medical_consultation"], profile),
}; };
private static string Compose(IEnumerable<string> sections) => private static string Compose(IEnumerable<string> sections) =>

View File

@@ -1,5 +1,6 @@
using Health.Application.Reports; using Health.Application.Reports;
using Health.Application.Notifications; using Health.Application.Notifications;
using Health.Application.AI;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using UglyToad.PdfPig; using UglyToad.PdfPig;
@@ -177,7 +178,9 @@ public sealed class ReportAnalysisService(
return text.Length > MaxPdfChars ? text[..MaxPdfChars] : text; 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 = $""" var prompt = $"""
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。 你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
@@ -197,14 +200,21 @@ public sealed class ReportAnalysisService(
var messages = new List<ChatMessage> var messages = new List<ChatMessage>
{ {
new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" }, new()
{
Role = "system",
Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。"
+ "\n\n所有医学解释和建议必须使用下面固定来源并在回答末尾保留可点击来源链接。"
+ "\n\n"
+ MedicalCitationKnowledge.Prompt,
},
new() { Role = "user", Content = prompt } new() { Role = "user", Content = prompt }
}; };
var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct); var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct);
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? ""; var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
return !string.IsNullOrWhiteSpace(summary) return !string.IsNullOrWhiteSpace(summary)
? summary ? $"{summary}\n\n{MedicalCitationKnowledge.FixedReportCitation.Trim()}"
: throw new InvalidOperationException("AI 未返回有效解读内容"); : throw new InvalidOperationException("AI 未返回有效解读内容");
} }

View File

@@ -44,6 +44,7 @@ public static class AiChatEndpoints
IAiConversationService conversations, IAiConversationService conversations,
IAttachmentContextBuilder attachments, IAttachmentContextBuilder attachments,
IPatientContextService patientContexts, IPatientContextService patientContexts,
IClientPolicyContext clientPolicy,
CancellationToken ct) => CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
@@ -122,6 +123,8 @@ public static class AiChatEndpoints
以上为 AI 安全提醒,不能替代医生诊断和治疗建议。 以上为 AI 安全提醒,不能替代医生诊断和治疗建议。
"""; """;
if (clientPolicy.IsIos)
urgentResponse += "\n\n" + MedicalCitationKnowledge.EmergencyCitation.Trim();
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct); await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, 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, // Only compose the modules selected for this turn. This keeps entry,
// query and consultation rules from competing in one giant prompt. // query and consultation rules from competing in one giant prompt.
var systemPrompt = parsedType == AgentType.Unified var systemPrompt = parsedType == AgentType.Unified
? promptManager.ComposeForIntents(intentRoute.Intents) ? promptManager.ComposeForIntents(intentRoute.Intents, clientPolicy.Profile)
: promptManager.GetSystemPrompt(parsedType); : promptManager.GetSystemPrompt(parsedType, clientPolicy.Profile);
var patientContext = await patientContexts.BuildAsync(userId.Value, ct); var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
var beijingNow = DateTime.UtcNow.AddHours(8); var beijingNow = DateTime.UtcNow.AddHours(8);
@@ -245,8 +248,8 @@ public static class AiChatEndpoints
// Tool Calling 循环 // Tool Calling 循环
var tools = parsedType == AgentType.Unified var tools = parsedType == AgentType.Unified
? GetToolsForIntents(intentRoute.Intents) ? GetToolsForIntents(intentRoute.Intents, clientPolicy.Profile)
: GetToolsForAgent(parsedType); : GetToolsForAgent(parsedType, clientPolicy.Profile);
var requiredEntryTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var requiredEntryTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (intentRoute.DraftAction != "cancel") if (intentRoute.DraftAction != "cancel")
{ {
@@ -513,6 +516,20 @@ public static class AiChatEndpoints
messages, messages,
llmClient, llmClient,
ct); 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) if (!answerStreamed)
{ {
await StreamAnswerTextAsync(http, fullResponse, ct); await StreamAnswerTextAsync(http, fullResponse, ct);
@@ -602,6 +619,7 @@ public static class AiChatEndpoints
HttpContext http, HttpContext http,
DeepSeekClient llmClient, DeepSeekClient llmClient,
IPatientContextService patientContexts, IPatientContextService patientContexts,
IClientPolicyContext clientPolicy,
CancellationToken ct) => CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
@@ -635,6 +653,8 @@ public static class AiChatEndpoints
var commentary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim(); var commentary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
if (string.IsNullOrWhiteSpace(commentary)) if (string.IsNullOrWhiteSpace(commentary))
return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" }); 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 }); return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null });
}).RequireAuthorization(); }).RequireAuthorization();
@@ -847,7 +867,9 @@ public static class AiChatEndpoints
_ => AiIntentRoute.Compatibility, _ => 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>(); var tools = new List<ToolDefinition>();
foreach (var intent in intents.Distinct(StringComparer.OrdinalIgnoreCase)) foreach (var intent in intents.Distinct(StringComparer.OrdinalIgnoreCase))
@@ -879,21 +901,24 @@ public static class AiChatEndpoints
tools.AddRange([ tools.AddRange([
CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.QueryHealthRecordsTool,
CommonAgentHandler.CheckArchiveTool, CommonAgentHandler.CheckArchiveTool,
CommonAgentHandler.SearchMedicalKnowledgeTool,
]); ]);
if (profile == ClientPolicyProfile.AndroidStandard)
tools.Add(CommonAgentHandler.SearchMedicalKnowledgeTool);
break; break;
case "report_analysis": case "report_analysis":
tools.AddRange([ tools.AddRange([
ReportAgentHandler.AnalyzeReportTool, ReportAgentHandler.AnalyzeReportTool,
CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.QueryHealthRecordsTool,
CommonAgentHandler.SearchMedicalKnowledgeTool,
]); ]);
if (profile == ClientPolicyProfile.AndroidStandard)
tools.Add(CommonAgentHandler.SearchMedicalKnowledgeTool);
break; break;
case "diet_analysis": case "diet_analysis":
tools.AddRange([ tools.AddRange([
CommonAgentHandler.CheckArchiveTool, CommonAgentHandler.CheckArchiveTool,
CommonAgentHandler.SearchMedicalKnowledgeTool,
]); ]);
if (profile == ClientPolicyProfile.AndroidStandard)
tools.Add(CommonAgentHandler.SearchMedicalKnowledgeTool);
break; break;
} }
} }
@@ -904,7 +929,11 @@ public static class AiChatEndpoints
.ToList(); .ToList();
} }
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch private static List<ToolDefinition> GetToolsForAgent(
AgentType agentType,
ClientPolicyProfile profile)
{
List<ToolDefinition> tools = agentType switch
{ {
AgentType.Health => [.. HealthDataAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool], AgentType.Health => [.. HealthDataAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
AgentType.Medication => [.. MedicationAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool], AgentType.Medication => [.. MedicationAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
@@ -932,6 +961,15 @@ public static class AiChatEndpoints
], ],
_ => [.. 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 private static bool IsPersonalQueryTool(string toolName) => toolName is
"query_health_records" or "query_health_records" or
@@ -943,6 +981,50 @@ public static class AiChatEndpoints
"manage_medication" or "manage_medication" or
"manage_exercise"; "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) private static bool IsPersonalQueryToolCall(string toolName, string arguments)
{ {
if (!IsPersonalQueryTool(toolName)) if (!IsPersonalQueryTool(toolName))

View File

@@ -33,6 +33,7 @@ using Health.WebApi.BackgroundServices;
using Health.WebApi.Converters; using Health.WebApi.Converters;
using Health.WebApi.Endpoints; using Health.WebApi.Endpoints;
using Health.WebApi.Middleware; using Health.WebApi.Middleware;
using Health.WebApi.Services;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -107,6 +108,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
// ---- 业务服务 ---- // ---- 业务服务 ----
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IClientPolicyContext, ClientPolicyContext>();
builder.Services.AddSingleton<JwtProvider>(); builder.Services.AddSingleton<JwtProvider>();
builder.Services.AddHttpClient<BceSmsClient>(client => builder.Services.AddHttpClient<BceSmsClient>(client =>
{ {

View 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;
}

View File

@@ -26,10 +26,6 @@
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接支持的血压计等健康设备,以同步用户主动测量的健康数据</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要使用蓝牙连接支持的血压计等健康设备</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string> <string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>

View File

@@ -9,9 +9,22 @@ const String baseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: kReleaseMode defaultValue: kReleaseMode
? 'https://erpapi.datalumina.cn/xiaomai' ? '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 { class ApiException implements Exception {
final int? code; final int? code;
final String message; final String message;
@@ -56,7 +69,10 @@ class ApiClient {
baseUrl: baseUrl, baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15), connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 60), receiveTimeout: const Duration(seconds: 60),
headers: {'Content-Type': 'application/json'}, headers: {
'Content-Type': 'application/json',
'X-App-Policy': appPolicyProfile,
},
), ),
) { ) {
_dio.interceptors.add(_AuthInterceptor(this)); _dio.interceptors.add(_AuthInterceptor(this));

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'navigation_provider.dart'; import 'navigation_provider.dart';
@@ -120,9 +122,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'doctorFollowUpEdit': case 'doctorFollowUpEdit':
return DoctorFollowUpEditPage(id: params['id']); return DoctorFollowUpEditPage(id: params['id']);
case 'devices': case 'devices':
return const DeviceManagementPage(); return Platform.isIOS ? const HomePage() : const DeviceManagementPage();
case 'deviceScan': case 'deviceScan':
return const DeviceScanPage(); return Platform.isIOS ? const HomePage() : const DeviceScanPage();
case 'healthArchive': case 'healthArchive':
return const HealthArchivePage(); return const HealthArchivePage();
case 'followups': case 'followups':

View File

@@ -1296,7 +1296,7 @@ class ChatMessagesView extends ConsumerWidget {
pushRoute(ref, 'reports'); pushRoute(ref, 'reports');
break; break;
case 'device': case 'device':
pushRoute(ref, 'devices'); if (!Platform.isIOS) pushRoute(ref, 'devices');
break; break;
default: default:
if (uri.host.isNotEmpty) pushRoute(ref, uri.host); if (uri.host.isNotEmpty) pushRoute(ref, uri.host);
@@ -2199,6 +2199,7 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
isWide: true, isWide: true,
route: 'trend', route: 'trend',
), ),
if (!Platform.isIOS)
_AgentAction( _AgentAction(
label: '蓝牙录入', label: '蓝牙录入',
icon: Icons.bluetooth, icon: Icons.bluetooth,

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
@@ -49,6 +51,7 @@ class SettingsPage extends ConsumerWidget {
title: '长辈模式', title: '长辈模式',
onTap: () => pushRoute(ref, 'elderMode'), onTap: () => pushRoute(ref, 'elderMode'),
), ),
if (!Platform.isIOS)
_SettingsTile( _SettingsTile(
icon: LucideIcons.bluetooth, icon: LucideIcons.bluetooth,
title: '蓝牙设备', title: '蓝牙设备',

View File

@@ -63,7 +63,10 @@ class SseHandler {
cancelToken: cancelToken, cancelToken: cancelToken,
options: Options( options: Options(
responseType: ResponseType.stream, responseType: ResponseType.stream,
headers: {'Authorization': 'Bearer $token'}, headers: {
'Authorization': 'Bearer $token',
'X-App-Policy': appPolicyProfile,
},
), ),
); );

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
@@ -431,6 +433,7 @@ class _NavigationSection extends StatelessWidget {
route: 'exercisePlan', route: 'exercisePlan',
colors: AppColors.exerciseGradient.colors, colors: AppColors.exerciseGradient.colors,
), ),
if (!Platform.isIOS)
_NavItem( _NavItem(
icon: LucideIcons.bluetooth, icon: LucideIcons.bluetooth,
title: '设备', title: '设备',