feat: enforce iOS AI safety policy and citations
This commit is contained in:
30
backend/src/Health.Application/AI/ClientPolicyProfile.cs
Normal file
30
backend/src/Health.Application/AI/ClientPolicyProfile.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace Health.Application.AI;
|
||||||
|
|
||||||
|
public enum ClientPolicyProfile
|
||||||
|
{
|
||||||
|
Legacy,
|
||||||
|
AndroidStandard,
|
||||||
|
IosMedicalCitations,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ClientPolicyProfiles
|
||||||
|
{
|
||||||
|
public const string HeaderName = "X-App-Policy";
|
||||||
|
public const string AndroidStandardKey = "android-standard";
|
||||||
|
public const string IosMedicalCitationsKey = "ios-medical-citations";
|
||||||
|
public const string LegacyKey = "legacy";
|
||||||
|
|
||||||
|
public static string ToKey(this ClientPolicyProfile profile) => profile switch
|
||||||
|
{
|
||||||
|
ClientPolicyProfile.AndroidStandard => AndroidStandardKey,
|
||||||
|
ClientPolicyProfile.IosMedicalCitations => IosMedicalCitationsKey,
|
||||||
|
_ => LegacyKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IClientPolicyContext
|
||||||
|
{
|
||||||
|
ClientPolicyProfile Profile { get; }
|
||||||
|
string ProfileKey { get; }
|
||||||
|
bool IsIos { get; }
|
||||||
|
}
|
||||||
202
backend/src/Health.Application/AI/MedicalCitationKnowledge.cs
Normal file
202
backend/src/Health.Application/AI/MedicalCitationKnowledge.cs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
namespace Health.Application.AI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// iOS 审核策略使用的固定医学来源目录,不依赖在线 RAG。
|
||||||
|
/// </summary>
|
||||||
|
public static class MedicalCitationKnowledge
|
||||||
|
{
|
||||||
|
private static readonly string[] ApprovedSourceUrls =
|
||||||
|
[
|
||||||
|
"https://pubmed.ncbi.nlm.nih.gov/39210715/",
|
||||||
|
"https://pubmed.ncbi.nlm.nih.gov/39651981/",
|
||||||
|
"https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse",
|
||||||
|
"https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics",
|
||||||
|
"https://pubmed.ncbi.nlm.nih.gov/37128940/",
|
||||||
|
"https://pubmed.ncbi.nlm.nih.gov/40504596/",
|
||||||
|
"https://pubmed.ncbi.nlm.nih.gov/38059362/",
|
||||||
|
"https://pubmed.ncbi.nlm.nih.gov/39315436/",
|
||||||
|
"https://pubmed.ncbi.nlm.nih.gov/39480983/",
|
||||||
|
"https://pubmed.ncbi.nlm.nih.gov/40268322/",
|
||||||
|
"https://pubmed.ncbi.nlm.nih.gov/29729142/",
|
||||||
|
"https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines",
|
||||||
|
"https://medlineplus.gov/healthtopics.html",
|
||||||
|
];
|
||||||
|
|
||||||
|
public const string Prompt = """
|
||||||
|
固定医学参考来源目录(仅可引用下列来源,不得编造 PMID、DOI 或 URL):
|
||||||
|
|
||||||
|
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
|
||||||
|
适用:血压记录、血压趋势、单次血压不能单独诊断、复测和就医提醒。
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/39210715/
|
||||||
|
|
||||||
|
[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes—2025
|
||||||
|
适用:血糖记录、空腹或餐后状态、低血糖风险和血糖结果说明。
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/39651981/
|
||||||
|
|
||||||
|
[S-HR-01] All About Heart Rate, American Heart Association
|
||||||
|
适用:静息心率的客观说明和心率记录趋势。
|
||||||
|
https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse
|
||||||
|
|
||||||
|
[S-OXY-01] Pulse Oximeter Basics, U.S. Food and Drug Administration
|
||||||
|
适用:血氧读数局限、复测、症状和血氧记录说明。
|
||||||
|
https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
|
||||||
|
|
||||||
|
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
|
||||||
|
适用:一般饮食模式、食物营养特征、饮食图片估算局限。
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/37128940/
|
||||||
|
|
||||||
|
[S-DIET-02] Diet and nutrition in cardiovascular disease prevention
|
||||||
|
适用:一般心血管健康饮食信息,不用于个体化疾病饮食处方。
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/40504596/
|
||||||
|
|
||||||
|
[S-EX-01] Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
|
||||||
|
适用:一般运动计划记录和运动安全提醒,不用于疾病运动处方。
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/38059362/
|
||||||
|
|
||||||
|
[S-EX-02] Core Components of Cardiac Rehabilitation Programs: 2024 Update
|
||||||
|
适用:心脏康复相关安全边界,只能建议咨询专业人员,不能生成康复处方。
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/39315436/
|
||||||
|
|
||||||
|
[S-MED-01] Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
|
||||||
|
适用:用药计划记录、服药提醒和建议咨询医生或药师。
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/39480983/
|
||||||
|
|
||||||
|
[S-REPORT-01] Interpreting Normal Values and Reference Ranges for Laboratory Tests
|
||||||
|
适用:报告指标、参考范围和异常标记的客观说明。
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/40268322/
|
||||||
|
|
||||||
|
[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
|
||||||
|
适用:不同实验室参考范围差异和优先使用原报告参考范围。
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/29729142/
|
||||||
|
|
||||||
|
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
|
||||||
|
适用:危险症状安全提醒、及时联系当地急救服务和不要等待 AI 判断。
|
||||||
|
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
|
||||||
|
|
||||||
|
[S-GEN-01] Health Topics, MedlinePlus, U.S. National Library of Medicine
|
||||||
|
适用:固定目录没有更具体来源时的一般症状、疾病、检查、治疗和健康信息入口;不得据此生成诊断、处方或个体化治疗方案。
|
||||||
|
https://medlineplus.gov/healthtopics.html
|
||||||
|
|
||||||
|
来源使用边界:
|
||||||
|
- 血压:单次记录不能单独诊断高血压;应结合多次记录、测量条件、症状和医生评估;不得建议自行加药、减药或停药。
|
||||||
|
- 血糖:必须关注空腹/餐后等测量时段和既往用药;不能把一个统一目标套用给所有人;不得据此诊断糖尿病或指导自行调药。
|
||||||
|
- 心率:静息心率的客观范围不能替代诊断;应结合是否安静、是否有不适和变化趋势说明。
|
||||||
|
- 血氧:读数是估计值,可能受手部温度、活动、循环、皮肤因素和设备影响;应结合症状和复测结果,不得仅凭单个数值诊断或指导吸氧/调药。
|
||||||
|
- 饮食:图片不能精确确定重量、份量、烹调油和调味料;盐、糖、脂肪和热量只能作一般估算;不得根据疾病档案生成个体化饮食处方。
|
||||||
|
- 运动:只能提供一般运动计划记录和安全提醒;不得针对心血管疾病制定运动处方;有胸痛、晕厥或明显呼吸困难时应先就医评估。
|
||||||
|
- 用药:只能记录计划、查询任务和提醒;开始、停止、更换或调整药物应由医生或药师决定。
|
||||||
|
- 报告:优先使用报告本身提供的参考范围;标记高/低不等于诊断;不同实验室参考范围不能直接互换;应由医生结合症状和病史解释。
|
||||||
|
- 危险症状:胸部不适、呼吸困难、单侧无力、语言障碍、意识异常等应立即联系当地急救服务,不要等待 AI 继续判断。
|
||||||
|
""";
|
||||||
|
|
||||||
|
public const string FixedDietCitation = """
|
||||||
|
参考来源:
|
||||||
|
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/37128940/
|
||||||
|
""";
|
||||||
|
|
||||||
|
public const string FixedBloodPressureCitation = """
|
||||||
|
参考来源:
|
||||||
|
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/39210715/
|
||||||
|
""";
|
||||||
|
|
||||||
|
public const string FixedGlucoseCitation = """
|
||||||
|
参考来源:
|
||||||
|
[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes—2025
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/39651981/
|
||||||
|
""";
|
||||||
|
|
||||||
|
public const string FixedHeartRateCitation = """
|
||||||
|
参考来源:
|
||||||
|
[S-HR-01] All About Heart Rate, American Heart Association
|
||||||
|
https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse
|
||||||
|
""";
|
||||||
|
|
||||||
|
public const string FixedOxygenCitation = """
|
||||||
|
参考来源:
|
||||||
|
[S-OXY-01] Pulse Oximeter Basics, U.S. Food and Drug Administration
|
||||||
|
https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
|
||||||
|
""";
|
||||||
|
|
||||||
|
public const string FixedExerciseCitation = """
|
||||||
|
参考来源:
|
||||||
|
[S-EX-01] Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/38059362/
|
||||||
|
""";
|
||||||
|
|
||||||
|
public const string FixedMedicationCitation = """
|
||||||
|
参考来源:
|
||||||
|
[S-MED-01] Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/39480983/
|
||||||
|
""";
|
||||||
|
|
||||||
|
public const string FixedReportCitation = """
|
||||||
|
参考来源:
|
||||||
|
[S-REPORT-01] Interpreting Normal Values and Reference Ranges for Laboratory Tests
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/40268322/
|
||||||
|
[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
|
||||||
|
https://pubmed.ncbi.nlm.nih.gov/29729142/
|
||||||
|
""";
|
||||||
|
|
||||||
|
public const string EmergencyCitation = """
|
||||||
|
参考来源:
|
||||||
|
[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid
|
||||||
|
https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
|
||||||
|
""";
|
||||||
|
|
||||||
|
public const string GeneralHealthCitation = """
|
||||||
|
参考来源:
|
||||||
|
[S-GEN-01] Health Topics, MedlinePlus, U.S. National Library of Medicine
|
||||||
|
https://medlineplus.gov/healthtopics.html
|
||||||
|
""";
|
||||||
|
|
||||||
|
public static string SelectFallbackCitation(string context)
|
||||||
|
{
|
||||||
|
var citations = new List<string>();
|
||||||
|
AddWhen(citations, context, FixedBloodPressureCitation, "血压", "收缩压", "舒张压", "高压", "低压");
|
||||||
|
AddWhen(citations, context, FixedGlucoseCitation, "血糖", "葡萄糖", "空腹", "餐后", "糖尿病", "低血糖");
|
||||||
|
AddWhen(citations, context, FixedHeartRateCitation, "心率", "脉搏", "心跳");
|
||||||
|
AddWhen(citations, context, FixedOxygenCitation, "血氧", "氧饱和度", "spo2");
|
||||||
|
AddWhen(citations, context, FixedDietCitation, "饮食", "营养", "食物", "吃什么", "忌口", "热量");
|
||||||
|
AddWhen(citations, context, FixedExerciseCitation, "运动", "锻炼", "散步", "跑步", "力量训练", "康复训练");
|
||||||
|
AddWhen(citations, context, FixedMedicationCitation, "用药", "药物", "服药", "剂量", "药师", "停药", "换药");
|
||||||
|
AddWhen(citations, context, FixedReportCitation, "报告", "检验", "检查", "化验", "参考范围", "指标");
|
||||||
|
AddWhen(citations, context, EmergencyCitation, "急救", "胸痛", "呼吸困难", "意识异常", "单侧无力", "言语障碍");
|
||||||
|
|
||||||
|
if (citations.Count == 0)
|
||||||
|
citations.Add(GeneralHealthCitation);
|
||||||
|
|
||||||
|
return "参考来源:\n" + string.Join(
|
||||||
|
"\n",
|
||||||
|
citations
|
||||||
|
.Select(RemoveHeading)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.Select(value => value.Trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ContainsApprovedCitation(string response) =>
|
||||||
|
response.Contains("参考来源", StringComparison.Ordinal) &&
|
||||||
|
ApprovedSourceUrls.Any(url =>
|
||||||
|
response.Contains(url, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static void AddWhen(
|
||||||
|
ICollection<string> citations,
|
||||||
|
string context,
|
||||||
|
string citation,
|
||||||
|
params string[] keywords)
|
||||||
|
{
|
||||||
|
if (keywords.Any(keyword =>
|
||||||
|
context.Contains(keyword, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
citations.Add(citation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RemoveHeading(string citation)
|
||||||
|
{
|
||||||
|
const string heading = "参考来源:";
|
||||||
|
var value = citation.Trim();
|
||||||
|
return value.StartsWith(heading, StringComparison.Ordinal)
|
||||||
|
? value[heading.Length..].TrimStart()
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,6 +188,17 @@ public sealed class HealthRecordService(
|
|||||||
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "")
|
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = """
|
||||||
|
医疗信息来源引用(必须遵守):
|
||||||
|
- 当回复包含医疗事实、数值解释、饮食/运动/用药相关信息、报告解释、危险症状或就医建议时,必须在回复末尾添加“参考来源”。
|
||||||
|
- 只能引用本轮系统消息中“固定医学参考来源目录”里实际提供的来源,不得编造来源、PMID、DOI 或 URL。
|
||||||
|
- 引用格式必须是:
|
||||||
|
参考来源:
|
||||||
|
[来源编号] 来源标题
|
||||||
|
完整 URL
|
||||||
|
- 引用必须与本次回答的内容直接相关;不要堆砌无关来源。
|
||||||
|
- 如果固定医学参考来源目录没有提供相关来源,只能说明资料不足并建议咨询医生/药师,不得凭常识补充具体医疗建议。
|
||||||
|
- 纯业务操作回复(例如查询计划、创建待确认记录、说明上传状态)不需要添加医学来源。
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string IosScopeRules = """
|
||||||
|
当前 iOS 客户端范围:
|
||||||
|
- 当前 iOS 客户端不包含蓝牙设备页、设备绑定、血压仪连接或自动设备同步。
|
||||||
|
- 禁止生成 app://device 链接,禁止引导用户使用蓝牙设备功能。
|
||||||
|
- 健康数据均按用户手动输入或 AI 待确认记录处理。
|
||||||
|
- 当前 iOS 客户端不调用在线 search_medical_knowledge;医疗回答只能使用本轮提供的固定医学来源目录。
|
||||||
|
- health_entry、medication_entry、exercise_entry、personal_query 和 general_chat 只处理录入、查询或应用操作,不主动给出医疗建议;用户明确询问是否正常、原因、风险或如何改善时,必须进入 medical_consultation,并使用带来源的医学回答规则。
|
||||||
|
""";
|
||||||
|
|
||||||
/// <summary>
|
/// <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) =>
|
||||||
|
|||||||
@@ -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 未返回有效解读内容");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 =>
|
||||||
{
|
{
|
||||||
|
|||||||
27
backend/src/Health.WebApi/Services/ClientPolicyContext.cs
Normal file
27
backend/src/Health.WebApi/Services/ClientPolicyContext.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using Health.Application.AI;
|
||||||
|
|
||||||
|
namespace Health.WebApi.Services;
|
||||||
|
|
||||||
|
public sealed class ClientPolicyContext(IHttpContextAccessor accessor) : IClientPolicyContext
|
||||||
|
{
|
||||||
|
private readonly IHttpContextAccessor _accessor = accessor;
|
||||||
|
|
||||||
|
public ClientPolicyProfile Profile
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var value = _accessor.HttpContext?.Request.Headers[ClientPolicyProfiles.HeaderName]
|
||||||
|
.FirstOrDefault()
|
||||||
|
?.Trim();
|
||||||
|
return string.Equals(
|
||||||
|
value,
|
||||||
|
ClientPolicyProfiles.IosMedicalCitationsKey,
|
||||||
|
StringComparison.OrdinalIgnoreCase)
|
||||||
|
? ClientPolicyProfile.IosMedicalCitations
|
||||||
|
: ClientPolicyProfile.AndroidStandard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ProfileKey => Profile.ToKey();
|
||||||
|
public bool IsIos => Profile == ClientPolicyProfile.IosMedicalCitations;
|
||||||
|
}
|
||||||
@@ -26,10 +26,6 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: '蓝牙设备',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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: '设备',
|
||||||
|
|||||||
Reference in New Issue
Block a user