Compare commits
8 Commits
ae94ced2d5
...
release/io
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b5cec10a6 | ||
|
|
ad93e38b7e | ||
|
|
39c32f842b | ||
|
|
5cd3584ae9 | ||
|
|
28f704c98e | ||
|
|
0cb5b8e85a | ||
|
|
9cea41705e | ||
|
|
0d4fd88ce7 |
57
AGENTS.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 项目协作准则
|
||||
|
||||
本文件适用于整个 `health_project` 仓库。处理任务时按改动风险分级,验证强度与风险匹配,避免小改动套用重型流程。
|
||||
|
||||
## 默认规则
|
||||
|
||||
- 默认不执行 `git add`、`git commit`、`git push`、合并、回滚或创建分支;只有用户明确要求时才进行。
|
||||
- 保留工作区中用户已有的未提交改动,不覆盖、不清理、不顺带重构。
|
||||
- 数据库、上传文件、密钥、生产配置和用户数据不视为普通文件;任何写入、迁移、删除或恢复操作必须先获得用户明确确认。
|
||||
- 只修改用户指定的端和功能范围。发现相邻问题可以说明,但不要擅自扩大修改范围。
|
||||
|
||||
## 小改动:快速处理
|
||||
|
||||
适用于颜色、字号、间距、圆角、文案、图标、单个组件简单布局等不涉及状态、接口和数据的调整。
|
||||
|
||||
1. 确认具体文件和修改范围。
|
||||
2. 直接完成修改。
|
||||
3. 执行格式化。
|
||||
4. 进行一次快速编译或静态检查,二者按实际需要选择。
|
||||
5. 简要说明改动结果。
|
||||
|
||||
不运行完整测试套件,不额外编写计划文档,不提交 Git。
|
||||
|
||||
## 中等改动:针对性验证
|
||||
|
||||
适用于页面交互、Provider、路由、表单逻辑、接口调用、跨端数据展示、启动脚本和配置修改。
|
||||
|
||||
1. 检查相关代码和现有改动。
|
||||
2. 修改前简要说明方案。
|
||||
3. 完成代码修改和格式化。
|
||||
4. 运行静态检查或编译。
|
||||
5. 只运行与本次改动直接相关的测试。
|
||||
6. 必要时启动对应页面或接口验证一次。
|
||||
|
||||
默认不提交 Git。
|
||||
|
||||
## 大改动:完整验证
|
||||
|
||||
适用于完整新功能、多页面联动、登录、聊天、蓝牙、AI 核心流程、后端结构调整和大范围重构。
|
||||
|
||||
1. 阅读相关代码、文档和交接资料。
|
||||
2. 与用户确认范围、交互和成功标准。
|
||||
3. 分步骤实施,并在关键阶段做针对性检查。
|
||||
4. 完成后运行 Flutter 和后端相关完整测试。
|
||||
5. 汇总改动、验证结果和遗留问题。
|
||||
|
||||
## 高风险改动:确认后执行
|
||||
|
||||
适用于数据库迁移或数据修复、大量删除、用户数据、上传文件、签名、生产配置、部署,以及 Git 提交、推送、合并和回滚。
|
||||
|
||||
1. 先进行只读检查,确认精确目标和影响。
|
||||
2. 向用户说明风险、影响范围和恢复方式。
|
||||
3. 必要时先制作可验证的备份。
|
||||
4. 获得用户明确确认后再执行。
|
||||
5. 执行后进行完整验证并报告结果。
|
||||
|
||||
用户在具体任务中的直接指令始终优先于本文件。
|
||||
@@ -16,6 +16,14 @@ QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
QWEN_API_KEY=sk-your-key-here
|
||||
QWEN_VISION_MODEL=qwen-vl-max
|
||||
|
||||
# 阿里云百炼实时语音输入(Fun-ASR)
|
||||
DASHSCOPE_ASR_API_KEY=sk-your-dashscope-key-here
|
||||
DASHSCOPE_ASR_WORKSPACE_ID=your-workspace-id
|
||||
DASHSCOPE_ASR_MODEL=fun-asr-realtime
|
||||
DASHSCOPE_ASR_MAX_DURATION_SECONDS=60
|
||||
# 可选;不填时按 Workspace ID 自动生成北京地域专属地址
|
||||
# DASHSCOPE_ASR_WEBSOCKET_URL=wss://your-workspace-id.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
|
||||
24
backend/src/Health.Application/AI/AiEntryDraftContracts.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed record AiEntryDraft(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
Guid ConversationId,
|
||||
string EntryType,
|
||||
string Payload,
|
||||
string MissingFields,
|
||||
DateTime ExpiresAt);
|
||||
|
||||
public interface IAiEntryDraftStore
|
||||
{
|
||||
Task<AiEntryDraft?> GetActiveAsync(Guid userId, Guid conversationId, string entryType, CancellationToken ct);
|
||||
Task<AiEntryDraft> UpsertAsync(
|
||||
Guid userId,
|
||||
Guid conversationId,
|
||||
string entryType,
|
||||
string payload,
|
||||
string missingFields,
|
||||
TimeSpan lifetime,
|
||||
CancellationToken ct);
|
||||
Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct);
|
||||
}
|
||||
@@ -16,5 +16,5 @@ public interface IAttachmentContextBuilder
|
||||
/// <summary>
|
||||
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
|
||||
/// </summary>
|
||||
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct);
|
||||
Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ public static class DietCommentaryPolicy
|
||||
public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。";
|
||||
|
||||
public const string SystemPrompt = """
|
||||
你是健康管理 App 中的饮食建议助手。根据用户本餐食物和健康档案,直接输出2到3条简短建议。
|
||||
每条建议12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。
|
||||
优先围绕控盐控油、蔬菜和蛋白质搭配、血糖血脂风险及份量控制。
|
||||
不作诊断,不提供治疗、处方、停药或调药结论。语言简洁、专业、面向普通用户。
|
||||
你是健康管理 App 中的饮食整理助手。根据用户本餐食物和健康档案,直接输出2到3条简短事实描述。
|
||||
每条12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。
|
||||
优先围绕控盐控油、蔬菜和蛋白质搭配及份量控制描述客观事实(如“本餐盐分约5克”“蔬菜偏少”)。
|
||||
不作诊断,不提供治疗、处方、停药或调药结论,不给饮食建议,不基于疾病推断。语言简洁、专业、面向普通用户。
|
||||
如果输出了营养事实或饮食相关健康信息,末尾必须附上“参考来源”,使用固定医学来源目录中的 S-DIET-01 或 S-DIET-02。
|
||||
""";
|
||||
|
||||
public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) =>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace Health.Application.AI;
|
||||
|
||||
/// <summary>
|
||||
/// iOS 审核版内置医学来源目录。
|
||||
/// 这是固定提示词资料,不依赖在线 RAG 检索。
|
||||
/// </summary>
|
||||
public static class MedicalCitationKnowledge
|
||||
{
|
||||
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
|
||||
|
||||
来源使用边界:
|
||||
- 血压:单次记录不能单独诊断高血压;应结合多次记录、测量条件、症状和医生评估;不得建议自行加药、减药或停药。
|
||||
- 血糖:必须关注空腹/餐后等测量时段和既往用药;不能把一个统一目标套用给所有人;不得据此诊断糖尿病或指导自行调药。
|
||||
- 心率:静息心率的客观范围不能替代诊断;应结合是否安静、是否有不适和变化趋势说明。
|
||||
- 血氧:读数是估计值,可能受手部温度、活动、循环、皮肤因素和设备影响;应结合症状和复测结果,不得仅凭单个数值诊断或指导吸氧/调药。
|
||||
- 饮食:图片不能精确确定重量、份量、烹调油和调味料;盐、糖、脂肪和热量只能作一般估算;不得根据疾病档案生成个体化饮食处方。
|
||||
- 运动:只能提供一般运动计划记录和安全提醒;不得针对心血管疾病制定运动处方;有胸痛、晕厥或明显呼吸困难时应先就医评估。
|
||||
- 用药:只能记录计划、查询任务和提醒;开始、停止、更换或调整药物应由医生或药师决定。
|
||||
- 报告:优先使用报告本身提供的参考范围;标记高/低不等于诊断;不同实验室参考范围不能直接互换;应由医生结合症状和病史解释。
|
||||
- 危险症状:胸部不适、呼吸困难、单侧无力、语言障碍、意识异常等应立即联系当地急救服务,不要等待 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 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
|
||||
""";
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
using System.Text;
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.HealthRecords;
|
||||
using Health.Application.Medications;
|
||||
|
||||
namespace Health.Application.AI;
|
||||
|
||||
public sealed class PatientContextService(
|
||||
IHealthArchiveService archives,
|
||||
IHealthRecordService healthRecords,
|
||||
IMedicationService medications) : IPatientContextService
|
||||
IHealthRecordService healthRecords) : IPatientContextService
|
||||
{
|
||||
private readonly IHealthArchiveService _archives = archives;
|
||||
private readonly IHealthRecordService _healthRecords = healthRecords;
|
||||
private readonly IMedicationService _medications = medications;
|
||||
|
||||
public async Task<string> BuildAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await _archives.GetAsync(userId, ct);
|
||||
var recentRecords = (await _healthRecords.GetRecordsAsync(userId, null, 30, ct)).Take(10).ToList();
|
||||
var activeMedications = await _medications.ListActiveAsync(userId, ct);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
if (archive != null)
|
||||
@@ -36,23 +32,11 @@ public sealed class PatientContextService(
|
||||
if (archive.DietRestrictions.Count > 0) sb.AppendLine($"饮食限制: {string.Join(", ", archive.DietRestrictions)}");
|
||||
}
|
||||
|
||||
if (activeMedications.Count > 0)
|
||||
{
|
||||
sb.AppendLine("当前用药:");
|
||||
foreach (var medication in activeMedications.Take(20))
|
||||
{
|
||||
var times = medication.TimeOfDay.Count > 0
|
||||
? string.Join("/", medication.TimeOfDay.Select(t => t.ToString("HH:mm")))
|
||||
: "未设置时间";
|
||||
sb.AppendLine($" {medication.Name} {medication.Dosage ?? ""} {medication.Frequency} {times}");
|
||||
}
|
||||
}
|
||||
|
||||
if (recentRecords.Count > 0)
|
||||
{
|
||||
sb.AppendLine("近期健康数据:");
|
||||
foreach (var record in recentRecords)
|
||||
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({record.RecordedAt:MM-dd HH:mm})");
|
||||
sb.AppendLine($" {record.Type}: {RecordValue(record)} ({ToBeijing(record.RecordedAt):MM-dd HH:mm} 北京时间)");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
@@ -67,4 +51,15 @@ public sealed class PatientContextService(
|
||||
"Weight" => $"{record.Value}kg",
|
||||
_ => "—"
|
||||
};
|
||||
|
||||
private static DateTime ToBeijing(DateTime value)
|
||||
{
|
||||
var utc = value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
|
||||
};
|
||||
return utc.AddHours(8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
||||
.Select(i => new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted, scheduledDate = i.ScheduledDate })
|
||||
.ToList();
|
||||
var followUps = snapshot.FollowUps
|
||||
.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date)
|
||||
.Where(f => BeijingDate(f.ScheduledAt) == date)
|
||||
.Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() })
|
||||
.ToList();
|
||||
|
||||
@@ -67,7 +67,7 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
||||
entries.Add(new CalendarEntry("exercise", new { type = "exercise", name = item.ExerciseType, duration = item.DurationMinutes, isCompleted = item.IsCompleted, scheduledDate = item.ScheduledDate }));
|
||||
}
|
||||
|
||||
foreach (var followUp in snapshot.FollowUps.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == date))
|
||||
foreach (var followUp in snapshot.FollowUps.Where(f => BeijingDate(f.ScheduledAt) == date))
|
||||
{
|
||||
entries.Add(new CalendarEntry("followup", new
|
||||
{
|
||||
@@ -88,5 +88,14 @@ public sealed class CalendarService(ICalendarRepository calendar) : ICalendarSer
|
||||
&& (medication.StartDate == null || medication.StartDate <= date)
|
||||
&& (medication.EndDate == null || medication.EndDate >= date);
|
||||
|
||||
private static DateOnly BeijingDate(DateTime value)
|
||||
{
|
||||
// EF 从 timestamptz 读取的是 UTC;单元测试及旧内存对象可能是 Unspecified。
|
||||
var beijing = value.Kind == DateTimeKind.Unspecified
|
||||
? value
|
||||
: value.ToUniversalTime().AddHours(8);
|
||||
return DateOnly.FromDateTime(beijing);
|
||||
}
|
||||
|
||||
private sealed record CalendarEntry(string Type, object Value);
|
||||
}
|
||||
|
||||
@@ -42,19 +42,19 @@ public interface IExerciseService
|
||||
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool?> ToggleCheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<bool> CheckInAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IExerciseRepository
|
||||
{
|
||||
Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct);
|
||||
Task AddAsync(ExercisePlan plan, CancellationToken ct);
|
||||
void Delete(ExercisePlan plan);
|
||||
|
||||
@@ -33,8 +33,11 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), ScheduledDate = date, ExerciseType = exerciseType,
|
||||
DurationMinutes = duration, IsRestDay = false,
|
||||
Id = Guid.NewGuid(),
|
||||
ScheduledDate = date,
|
||||
ExerciseType = exerciseType,
|
||||
DurationMinutes = duration,
|
||||
IsRestDay = false,
|
||||
});
|
||||
}
|
||||
await SaveNewAsync(plan, ct);
|
||||
@@ -52,10 +55,13 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plans = await _exercises.ListAsync(userId, 20, ct);
|
||||
return plans.Select(p => new ExercisePlanSummaryDto(
|
||||
p.Id, p.StartDate, p.EndDate, p.ReminderTime, p.CreatedAt,
|
||||
p.Items.Count, p.Items.Count(i => i.IsCompleted),
|
||||
p.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList())).ToList();
|
||||
return plans.Select(ToSummaryDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAllAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plans = await _exercises.ListAllAsync(userId, ct);
|
||||
return plans.Select(ToSummaryDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct)
|
||||
@@ -103,12 +109,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ExercisePlanDto?> GetLatestAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var plan = await _exercises.GetLatestAsync(userId, ct);
|
||||
return plan == null ? null : ToDto(plan);
|
||||
}
|
||||
|
||||
private async Task SaveNewAsync(ExercisePlan plan, CancellationToken ct)
|
||||
{
|
||||
await _exercises.AddAsync(plan, ct);
|
||||
@@ -117,15 +117,24 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
|
||||
private static ExercisePlan NewPlan(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId, StartDate = startDate, EndDate = endDate,
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
StartDate = startDate,
|
||||
EndDate = endDate,
|
||||
ReminderTime = reminderTime ?? DefaultReminderTime,
|
||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static ExercisePlanDto ToDto(ExercisePlan plan) => new(
|
||||
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt, plan.UpdatedAt,
|
||||
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
|
||||
|
||||
private static ExercisePlanSummaryDto ToSummaryDto(ExercisePlan plan) => new(
|
||||
plan.Id, plan.StartDate, plan.EndDate, plan.ReminderTime, plan.CreatedAt,
|
||||
plan.Items.Count, plan.Items.Count(i => i.IsCompleted),
|
||||
plan.Items.OrderBy(i => i.ScheduledDate).Select(ToItemDto).ToList());
|
||||
|
||||
private static ExercisePlanItemDto ToItemDto(ExercisePlanItem item) => new(
|
||||
item.Id, item.ScheduledDate, item.ExerciseType, item.DurationMinutes,
|
||||
item.IsCompleted, item.CompletedAt, item.IsRestDay);
|
||||
|
||||
@@ -188,6 +188,16 @@ 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(
|
||||
|
||||
@@ -45,6 +45,21 @@ public sealed record MedicationReminderDto(
|
||||
string ScheduledTime,
|
||||
string Status);
|
||||
|
||||
public sealed record AiMedicationDoseDto(
|
||||
string ScheduledTime,
|
||||
string Status);
|
||||
|
||||
public sealed record AiMedicationPlanDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Dosage,
|
||||
string Frequency,
|
||||
DateOnly? StartDate,
|
||||
DateOnly? EndDate,
|
||||
string Phase,
|
||||
bool ScheduledOnDate,
|
||||
IReadOnlyList<AiMedicationDoseDto> Doses);
|
||||
|
||||
public sealed record MedicationReminderTask(
|
||||
Guid TaskId,
|
||||
Guid UserId,
|
||||
@@ -60,12 +75,10 @@ public interface IMedicationService
|
||||
Task<Guid> CreateAsync(Guid userId, MedicationUpsertRequest request, CancellationToken ct);
|
||||
Task<bool> UpdateAsync(Guid userId, Guid medicationId, MedicationPatchRequest request, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct);
|
||||
Task<bool> CancelDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(Guid userId, DateOnly date, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IMedicationRepository
|
||||
@@ -74,8 +87,6 @@ public interface IMedicationRepository
|
||||
Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct);
|
||||
Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct);
|
||||
Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
|
||||
|
||||
@@ -96,23 +96,6 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool?> ToggleTodayTakenAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
{
|
||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
||||
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var existing = await _medications.GetTodayTakenLogAsync(userId, medicationId, todayStartUtc, todayEndUtc, ct);
|
||||
if (existing != null)
|
||||
{
|
||||
_medications.DeleteLog(existing);
|
||||
await _medications.SaveChangesAsync(ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
await AddLogAsync(userId, medicationId, MedicationLogStatus.Taken, TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MedicationReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||
@@ -147,7 +130,14 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
|
||||
public async Task<bool?> ConfirmDoseAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, MedicationLogStatus status, CancellationToken ct)
|
||||
{
|
||||
if (!await _medications.ExistsOwnedAsync(userId, medicationId, ct)) return null;
|
||||
var medication = await _medications.GetOwnedAsync(userId, medicationId, ct);
|
||||
if (medication == null) return null;
|
||||
|
||||
var today = BeijingToday();
|
||||
if (!IsActiveOn(medication, today) ||
|
||||
!GetDosesForToday(medication, today) ||
|
||||
!medication.TimeOfDay.Contains(scheduledTime))
|
||||
return false;
|
||||
|
||||
var (todayStartUtc, todayEndUtc) = GetBeijingTodayUtcWindow();
|
||||
var existing = await _medications.DoseLogExistsAsync(userId, medicationId, scheduledTime, todayStartUtc, todayEndUtc, ct);
|
||||
@@ -168,18 +158,48 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MedicationDto>> ListActiveAsync(Guid userId, CancellationToken ct) =>
|
||||
ListAsync(userId, "active", ct);
|
||||
|
||||
public async Task<bool> ConfirmNowAsync(Guid userId, Guid medicationId, CancellationToken ct)
|
||||
public async Task<IReadOnlyList<AiMedicationPlanDto>> GetAiOverviewAsync(
|
||||
Guid userId,
|
||||
DateOnly date,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await ConfirmDoseAsync(
|
||||
userId,
|
||||
medicationId,
|
||||
TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
MedicationLogStatus.Taken,
|
||||
ct);
|
||||
return result == true;
|
||||
var medications = await _medications.ListAsync(userId, null, ct);
|
||||
var (startUtc, endUtc) = GetBeijingDateUtcWindow(date);
|
||||
var logs = await _medications.ListLogsInWindowAsync(userId, startUtc, endUtc, ct);
|
||||
var today = BeijingToday();
|
||||
var now = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
|
||||
return medications.Select(medication =>
|
||||
{
|
||||
var phase = ResolvePhase(medication, date);
|
||||
var scheduled = phase == "active" && GetDosesForToday(medication, date);
|
||||
var doses = scheduled
|
||||
? medication.TimeOfDay.OrderBy(time => time).Select(time =>
|
||||
{
|
||||
var log = logs.FirstOrDefault(item =>
|
||||
item.MedicationId == medication.Id && item.ScheduledTime == time);
|
||||
var status = log != null
|
||||
? log.Status.ToString().ToLowerInvariant()
|
||||
: date < today
|
||||
? "missed"
|
||||
: date > today
|
||||
? "scheduled"
|
||||
: MedicationScheduleStatus.Resolve(time, now, null);
|
||||
return new AiMedicationDoseDto(time.ToString("HH:mm"), status);
|
||||
}).ToList()
|
||||
: [];
|
||||
|
||||
return new AiMedicationPlanDto(
|
||||
medication.Id,
|
||||
medication.Name,
|
||||
medication.Dosage,
|
||||
medication.Frequency.ToString(),
|
||||
medication.StartDate,
|
||||
medication.EndDate,
|
||||
phase,
|
||||
scheduled,
|
||||
doses);
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private async Task AddLogAsync(Guid userId, Guid medicationId, MedicationLogStatus status, TimeOnly scheduledTime, CancellationToken ct)
|
||||
@@ -217,9 +237,28 @@ public sealed class MedicationService(IMedicationRepository medications) : IMedi
|
||||
|
||||
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingTodayUtcWindow()
|
||||
{
|
||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
||||
var todayStartUtc = beijingToday.AddHours(-8);
|
||||
return (todayStartUtc, todayStartUtc.AddDays(1));
|
||||
return GetBeijingDateUtcWindow(BeijingToday());
|
||||
}
|
||||
|
||||
private static (DateTime StartUtc, DateTime EndUtc) GetBeijingDateUtcWindow(DateOnly date)
|
||||
{
|
||||
var startUtc = DateTime.SpecifyKind(date.ToDateTime(TimeOnly.MinValue).AddHours(-8), DateTimeKind.Utc);
|
||||
return (startUtc, startUtc.AddDays(1));
|
||||
}
|
||||
|
||||
private static DateOnly BeijingToday() => DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
|
||||
private static bool IsActiveOn(Medication medication, DateOnly date) =>
|
||||
medication.IsActive &&
|
||||
(!medication.StartDate.HasValue || medication.StartDate.Value <= date) &&
|
||||
(!medication.EndDate.HasValue || medication.EndDate.Value >= date);
|
||||
|
||||
private static string ResolvePhase(Medication medication, DateOnly date)
|
||||
{
|
||||
if (!medication.IsActive) return "inactive";
|
||||
if (medication.StartDate.HasValue && medication.StartDate.Value > date) return "upcoming";
|
||||
if (medication.EndDate.HasValue && medication.EndDate.Value < date) return "ended";
|
||||
return "active";
|
||||
}
|
||||
|
||||
private static bool GetDosesForToday(Medication med, DateOnly today)
|
||||
|
||||
@@ -94,7 +94,7 @@ public sealed class ReportService(
|
||||
public static ReportDto ToDto(Report report) => new(
|
||||
report.Id,
|
||||
report.UserId,
|
||||
report.FileUrl,
|
||||
$"/api/reports/{report.Id}/file",
|
||||
report.FileType.ToString(),
|
||||
report.Category.ToString(),
|
||||
report.Status.ToString(),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace Health.Application.Speech;
|
||||
|
||||
public interface IRealtimeSpeechRecognitionProxy
|
||||
{
|
||||
bool IsConfigured { get; }
|
||||
|
||||
Task ProxyAsync(WebSocket clientSocket, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -14,7 +14,8 @@ public sealed record UserProfileDto(
|
||||
public sealed record UserProfileUpdateRequest(
|
||||
string? Name,
|
||||
string? Gender,
|
||||
DateOnly? BirthDate);
|
||||
DateOnly? BirthDate,
|
||||
string? AvatarUrl);
|
||||
|
||||
public sealed record AccountFileReferences(
|
||||
IReadOnlyList<string> FileUrls,
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class UserService(
|
||||
if (request.Name != null) user.Name = request.Name;
|
||||
if (request.Gender != null) user.Gender = request.Gender;
|
||||
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
|
||||
if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl;
|
||||
user.UpdatedAt = DateTime.UtcNow;
|
||||
await _users.SaveChangesAsync(ct);
|
||||
return true;
|
||||
|
||||
@@ -136,10 +136,10 @@ public enum FollowUpStatus
|
||||
public enum AgentType
|
||||
{
|
||||
Default, // 默认对话
|
||||
Consultation, // AI 问诊
|
||||
Consultation, // AI 对话
|
||||
Health, // 记数据
|
||||
Diet, // 拍饮食
|
||||
Medication, // 药管家
|
||||
Medication, // 药提醒
|
||||
Report, // 看报告
|
||||
Exercise, // 运动计划
|
||||
Unified // 统一入口(自动路由)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
internal static partial class AiDateTime
|
||||
{
|
||||
public static DateTime BeijingNow => DateTime.UtcNow.AddHours(8);
|
||||
public static DateOnly BeijingToday => DateOnly.FromDateTime(BeijingNow);
|
||||
|
||||
public static (DateTime StartUtc, DateTime EndUtc) BeijingDateWindowUtc(DateOnly date)
|
||||
{
|
||||
var startUtc = DateTime.SpecifyKind(date.ToDateTime(TimeOnly.MinValue).AddHours(-8), DateTimeKind.Utc);
|
||||
return (startUtc, startUtc.AddDays(1));
|
||||
}
|
||||
|
||||
public static DateTime ToUtc(DateTime value) => value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
public static string ToBeijingText(DateTime value) =>
|
||||
ToUtc(value).AddHours(8).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
|
||||
|
||||
public static bool TryParseUserDateTime(string? value, out DateTime utc)
|
||||
{
|
||||
utc = default;
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
|
||||
if ((value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || OffsetSuffix().IsMatch(value)) &&
|
||||
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var offsetValue))
|
||||
{
|
||||
utc = offsetValue.UtcDateTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out var beijingValue))
|
||||
return false;
|
||||
|
||||
utc = DateTime.SpecifyKind(beijingValue, DateTimeKind.Unspecified).AddHours(-8);
|
||||
utc = DateTime.SpecifyKind(utc, DateTimeKind.Utc);
|
||||
return true;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"[+-]\d{2}:?\d{2}$")]
|
||||
private static partial Regex OffsetSuffix();
|
||||
}
|
||||
@@ -8,12 +8,47 @@ namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
/// </summary>
|
||||
public static class CommonAgentHandler
|
||||
{
|
||||
public static readonly ToolDefinition SearchMedicalKnowledgeTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "search_medical_knowledge",
|
||||
Description = "按需检索医学知识库。仅用于症状分析、健康建议、疾病/药品/检查指标解释等需要医学资料支撑的回答;健康数据录入、用药或运动计划创建/查询/打卡、普通寒暄不得调用。",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
query = new
|
||||
{
|
||||
type = "string",
|
||||
description = "需要检索的医学问题,保留关键症状、疾病、药品或指标名称",
|
||||
},
|
||||
},
|
||||
required = new[] { "query" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static readonly ToolDefinition QueryHealthRecordsTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "query_health_records", Description = "查询近期健康数据",
|
||||
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } }
|
||||
Name = "query_health_records",
|
||||
Description = "查询近期健康数据",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight", "all" }, description = "健康指标类型" },
|
||||
scope = new { type = "string", @enum = new[] { "today", "yesterday", "date", "recent_days", "range" }, description = "日期范围,默认 recent_days" },
|
||||
date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" },
|
||||
start_date = new { type = "string", description = "scope=range 时的北京时间开始日期 yyyy-MM-dd" },
|
||||
end_date = new { type = "string", description = "scope=range 时的北京时间结束日期 yyyy-MM-dd(包含)" },
|
||||
days = new { type = "integer", description = "scope=recent_days 时的天数,1到365" },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,10 +77,67 @@ public static class CommonAgentHandler
|
||||
|
||||
private static async Task<object> ExecuteQueryHealthRecords(IHealthRecordService healthRecords, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
|
||||
var records = await healthRecords.GetRecordsAsync(userId, type, days, ct);
|
||||
return new { count = records.Count, records };
|
||||
var rawType = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
if (!TryNormalizeMetricType(rawType, out var metricType))
|
||||
return new { success = false, message = $"无法识别健康指标类型: {rawType}" };
|
||||
|
||||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||
? scopeValue.GetString()?.ToLowerInvariant()
|
||||
: "recent_days";
|
||||
DateTime? startUtc = null;
|
||||
DateTime? endUtc = null;
|
||||
int days;
|
||||
|
||||
switch (scope)
|
||||
{
|
||||
case "today":
|
||||
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday);
|
||||
days = 2;
|
||||
break;
|
||||
case "yesterday":
|
||||
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday.AddDays(-1));
|
||||
days = 3;
|
||||
break;
|
||||
case "date":
|
||||
if (!TryReadDate(args, "date", out var date))
|
||||
return new { success = false, message = "查询指定日期时必须提供有效的 date" };
|
||||
(startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date);
|
||||
days = DaysSince(startUtc.Value);
|
||||
break;
|
||||
case "range":
|
||||
if (!TryReadDate(args, "start_date", out var startDate) ||
|
||||
!TryReadDate(args, "end_date", out var endDate) || endDate < startDate)
|
||||
return new { success = false, message = "必须提供有效的 start_date 和 end_date,且结束日期不能早于开始日期" };
|
||||
startUtc = AiDateTime.BeijingDateWindowUtc(startDate).StartUtc;
|
||||
endUtc = AiDateTime.BeijingDateWindowUtc(endDate).EndUtc;
|
||||
days = DaysSince(startUtc.Value);
|
||||
break;
|
||||
default:
|
||||
days = args.TryGetProperty("days", out var d) && d.TryGetInt32(out var parsedDays)
|
||||
? Math.Clamp(parsedDays, 1, 365)
|
||||
: 7;
|
||||
scope = "recent_days";
|
||||
break;
|
||||
}
|
||||
|
||||
var records = await healthRecords.GetRecordsAsync(userId, metricType, days, ct);
|
||||
var filtered = records
|
||||
.Where(record => !startUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) >= startUtc.Value)
|
||||
.Where(record => !endUtc.HasValue || AiDateTime.ToUtc(record.RecordedAt) < endUtc.Value)
|
||||
.Select(record => new
|
||||
{
|
||||
record.Id,
|
||||
record.Type,
|
||||
record.Systolic,
|
||||
record.Diastolic,
|
||||
record.Value,
|
||||
record.Unit,
|
||||
record.Source,
|
||||
record.IsAbnormal,
|
||||
recorded_at_beijing = AiDateTime.ToBeijingText(record.RecordedAt),
|
||||
})
|
||||
.ToList();
|
||||
return new { success = true, scope, count = filtered.Count, records = filtered };
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteCheckArchive(IHealthArchiveService archives, Guid userId, CancellationToken ct)
|
||||
@@ -54,55 +146,39 @@ public static class CommonAgentHandler
|
||||
if (archive == null) return new { found = false };
|
||||
return new
|
||||
{
|
||||
found = true, archive.Diagnosis, archive.SurgeryType,
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries,
|
||||
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
||||
found = true,
|
||||
archive.Diagnosis,
|
||||
archive.SurgeryType,
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||
archive.Surgeries,
|
||||
archive.Allergies,
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
};
|
||||
}
|
||||
|
||||
public static readonly ToolDefinition ManageArchiveTool = new()
|
||||
private static bool TryNormalizeMetricType(string? value, out string? normalized)
|
||||
{
|
||||
Function = new()
|
||||
normalized = value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
Name = "manage_archive", Description = "管理用户健康档案",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "update_diagnosis/update_surgery/update_allergies/update_chronic_diseases/update_diet_restrictions/query" },
|
||||
diagnosis = new { type = "string" },
|
||||
surgery_type = new { type = "string" },
|
||||
surgery_date = new { type = "string" },
|
||||
allergies = new { type = "array", items = new { type = "string" } },
|
||||
chronic_diseases = new { type = "array", items = new { type = "string" } },
|
||||
diet_restrictions = new { type = "array", items = new { type = "string" } },
|
||||
},
|
||||
required = new[] { "action" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static Task<object> ExecuteManageArchive(
|
||||
IHealthArchiveService archives,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||
var request = new HealthArchiveUpdateRequest(
|
||||
args.TryGetProperty("diagnosis", out var diagnosis) ? diagnosis.GetString() : null,
|
||||
args.TryGetProperty("surgery_type", out var surgeryType) ? surgeryType.GetString() : null,
|
||||
args.TryGetProperty("surgery_date", out var surgeryDate) && DateOnly.TryParse(surgeryDate.GetString(), out var date) ? date : null,
|
||||
ReadStringArray(args, "allergies"),
|
||||
ReadStringArray(args, "diet_restrictions"),
|
||||
ReadStringArray(args, "chronic_diseases"),
|
||||
args.TryGetProperty("family_history", out var familyHistory) ? familyHistory.GetString() : null);
|
||||
return archives.ExecuteAiActionAsync(userId, action, request, ct);
|
||||
null or "" or "all" or "全部" => null,
|
||||
"bloodpressure" or "blood_pressure" or "血压" => "BloodPressure",
|
||||
"heartrate" or "heart_rate" or "心率" or "脉搏" => "HeartRate",
|
||||
"glucose" or "blood_glucose" or "血糖" => "Glucose",
|
||||
"spo2" or "blood_oxygen" or "血氧" or "血氧饱和度" => "SpO2",
|
||||
"weight" or "体重" => "Weight",
|
||||
_ => "__invalid__",
|
||||
};
|
||||
return normalized != "__invalid__";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? ReadStringArray(JsonElement args, string propertyName) =>
|
||||
args.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.Array
|
||||
? value.EnumerateArray().Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
|
||||
: null;
|
||||
private static bool TryReadDate(JsonElement args, string name, out DateOnly date)
|
||||
{
|
||||
date = default;
|
||||
return args.TryGetProperty(name, out var value) && DateOnly.TryParse(value.GetString(), out date);
|
||||
}
|
||||
|
||||
private static int DaysSince(DateTime startUtc) =>
|
||||
Math.Clamp((int)Math.Ceiling((DateTime.UtcNow - startUtc).TotalDays) + 1, 1, 3650);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ public static class ExerciseAgentHandler
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "create/query/checkin" },
|
||||
action = new { type = "string", @enum = new[] { "create", "query", "checkin" }, description = "create/query/checkin" },
|
||||
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "all_plans" }, description = "查询时必填:今日任务/指定日期任务/当前计划/即将开始/已结束/全部计划" },
|
||||
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
|
||||
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd,默认今天" },
|
||||
duration_days = new { type = "integer", description = "连续运动天数,如一周为7天" },
|
||||
exercise_type = new { type = "string", description = "运动类型,如散步、慢跑、游泳" },
|
||||
@@ -30,20 +32,50 @@ public static class ExerciseAgentHandler
|
||||
|
||||
public static List<ToolDefinition> Tools => [ManageExerciseTool];
|
||||
|
||||
public static string? ValidateWriteArguments(JsonElement args)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var actionValue)
|
||||
? actionValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (action != "create") return null;
|
||||
|
||||
if (!args.TryGetProperty("exercise_type", out var typeValue) ||
|
||||
string.IsNullOrWhiteSpace(typeValue.GetString()))
|
||||
return "创建运动计划前需要确认运动项目";
|
||||
if (!args.TryGetProperty("duration_minutes", out var minutesValue) ||
|
||||
!minutesValue.TryGetInt32(out var minutes) || minutes is <= 0 or > 300)
|
||||
return "创建运动计划前需要确认合理的单次运动时长";
|
||||
if (!args.TryGetProperty("duration_days", out var daysValue) ||
|
||||
!daysValue.TryGetInt32(out var days) || days is <= 0 or > 366)
|
||||
return "创建运动计划前需要确认计划持续天数";
|
||||
if (!args.TryGetProperty("start_date", out var startValue) ||
|
||||
!DateOnly.TryParse(startValue.GetString(), out _))
|
||||
return "创建运动计划前需要确认开始日期";
|
||||
if (!args.TryGetProperty("reminder_time", out var reminderValue) ||
|
||||
!TimeOnly.TryParse(reminderValue.GetString(), out _))
|
||||
return "创建运动计划前需要确认具体提醒时间";
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
AppDbContext db,
|
||||
Guid userId,
|
||||
IExerciseService? exercises = null,
|
||||
IExerciseService exercises,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (toolName != "manage_exercise" || exercises == null)
|
||||
if (toolName != "manage_exercise")
|
||||
return new { success = false, message = $"未知工具: {toolName}" };
|
||||
|
||||
var action = args.TryGetProperty("action", out var actionValue) ? actionValue.GetString() : "query";
|
||||
var action = args.TryGetProperty("action", out var actionValue)
|
||||
? actionValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (action == "create")
|
||||
{
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
|
||||
? parsedStart
|
||||
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
@@ -67,17 +99,111 @@ public static class ExerciseAgentHandler
|
||||
return success ? new { success = true } : new { success = false, message = "条目不存在" };
|
||||
}
|
||||
|
||||
var plan = await exercises.GetLatestAsync(userId, ct);
|
||||
return plan == null
|
||||
? new { found = false }
|
||||
: new
|
||||
if (action != "query")
|
||||
return new { success = false, message = $"未知操作: {action}" };
|
||||
|
||||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||
? scopeValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "all_plans"))
|
||||
return new
|
||||
{
|
||||
found = true,
|
||||
plan_id = plan.Id,
|
||||
plan.StartDate,
|
||||
plan.EndDate,
|
||||
plan.ReminderTime,
|
||||
items = plan.Items.Select(i => new { i.Id, i.ScheduledDate, i.ExerciseType, i.DurationMinutes, i.IsCompleted }),
|
||||
success = false,
|
||||
message = "查询运动计划时必须明确 scope:today、scheduled_date、current_plans、upcoming_plans、ended_plans 或 all_plans",
|
||||
};
|
||||
if (scope == "scheduled_date" &&
|
||||
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
|
||||
return new { success = false, message = "查询指定日期的运动安排时必须提供有效的 date" };
|
||||
var queryDate = scope == "scheduled_date"
|
||||
? DateOnly.Parse(args.GetProperty("date").GetString()!)
|
||||
: AiDateTime.BeijingToday;
|
||||
var isDateScope = scope is "today" or "scheduled_date";
|
||||
var plans = await exercises.ListAllAsync(userId, ct);
|
||||
var overview = plans.Select(plan => new
|
||||
{
|
||||
plan_id = plan.Id,
|
||||
plan.StartDate,
|
||||
plan.EndDate,
|
||||
phase = queryDate < plan.StartDate ? "upcoming" : queryDate > plan.EndDate ? "ended" : "active",
|
||||
plan.ReminderTime,
|
||||
total_items = plan.Items.Count,
|
||||
completed_items = plan.Items.Count(item => item.IsCompleted),
|
||||
returned_items = isDateScope
|
||||
? plan.Items.Count(item => item.ScheduledDate == queryDate)
|
||||
: Math.Min(plan.Items.Count, 14),
|
||||
items_truncated = !isDateScope && plan.Items.Count > 14,
|
||||
items = plan.Items
|
||||
.Where(item => !isDateScope || item.ScheduledDate == queryDate)
|
||||
.OrderBy(item => item.ScheduledDate)
|
||||
.Take(isDateScope ? int.MaxValue : 14)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.ScheduledDate,
|
||||
item.ExerciseType,
|
||||
item.DurationMinutes,
|
||||
item.IsCompleted,
|
||||
item.IsRestDay,
|
||||
completed_at_beijing = item.CompletedAt.HasValue ? AiDateTime.ToBeijingText(item.CompletedAt.Value) : null,
|
||||
})
|
||||
.ToList(),
|
||||
});
|
||||
var filtered = scope switch
|
||||
{
|
||||
"all_plans" => overview,
|
||||
"current_plans" => overview.Where(plan => plan.phase == "active"),
|
||||
"upcoming_plans" => overview
|
||||
.Where(plan => plan.phase == "upcoming")
|
||||
.OrderBy(plan => plan.StartDate),
|
||||
"ended_plans" => overview
|
||||
.Where(plan => plan.phase == "ended")
|
||||
.OrderByDescending(plan => plan.EndDate),
|
||||
_ => overview.Where(plan => plan.phase == "active" && plan.items.Count > 0),
|
||||
};
|
||||
var result = filtered.ToList();
|
||||
var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).ToList();
|
||||
var nextPlan = plans.Where(plan => plan.StartDate > queryDate).OrderBy(plan => plan.StartDate).FirstOrDefault();
|
||||
string authoritativeAnswer;
|
||||
if (result.Count == 0)
|
||||
{
|
||||
authoritativeAnswer = isDateScope
|
||||
? $"没有查到 {queryDate:yyyy-MM-dd} 的运动安排。"
|
||||
: "当前没有查到符合条件的运动计划。";
|
||||
}
|
||||
else if (isDateScope)
|
||||
{
|
||||
var taskLines = scheduledTasks.Select(item =>
|
||||
$"- {item.ExerciseType}:{item.DurationMinutes} 分钟,{(item.IsCompleted ? "已完成" : "未完成")}");
|
||||
authoritativeAnswer =
|
||||
$"{queryDate:yyyy-MM-dd} 的运动安排:\n{string.Join('\n', taskLines)}";
|
||||
}
|
||||
else
|
||||
{
|
||||
var planLines = result.Select(plan =>
|
||||
{
|
||||
var representative = plan.items.FirstOrDefault(item => !item.IsRestDay);
|
||||
var exerciseType = representative?.ExerciseType ?? "运动";
|
||||
var durationMinutes = representative?.DurationMinutes ?? 0;
|
||||
return $"- {exerciseType}:{plan.StartDate:yyyy-MM-dd} 至 {plan.EndDate:yyyy-MM-dd},"
|
||||
+ $"每天 {durationMinutes} 分钟,提醒时间 {plan.ReminderTime:HH:mm}";
|
||||
});
|
||||
authoritativeAnswer =
|
||||
$"查到 {result.Count} 个符合条件的运动计划:\n{string.Join('\n', planLines)}";
|
||||
}
|
||||
return new
|
||||
{
|
||||
found = result.Count > 0,
|
||||
query_date = queryDate.ToString("yyyy-MM-dd"),
|
||||
scope,
|
||||
count = result.Count,
|
||||
all_tasks_completed = isDateScope
|
||||
? scheduledTasks.Count > 0 && scheduledTasks.All(item => item.IsCompleted)
|
||||
: (bool?)null,
|
||||
has_upcoming_plans = nextPlan != null,
|
||||
next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"),
|
||||
days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber,
|
||||
plans = result,
|
||||
authoritative_answer = authoritativeAnswer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,35 +7,162 @@ namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
/// </summary>
|
||||
public static class HealthDataAgentHandler
|
||||
{
|
||||
private static readonly string[] MetricTypes = ["blood_pressure", "heart_rate", "glucose", "spo2", "weight"];
|
||||
|
||||
public static readonly ToolDefinition RecordHealthDataTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
|
||||
Parameters = new { type = "object", properties = new { type = new { type = "string" }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" } }, required = new[] { "type" } }
|
||||
Name = "record_health_data",
|
||||
Description = "记录健康数据(血压/心率/血糖/血氧/体重)。用户说“脉搏”时按“心率”处理,type 使用 heart_rate,数值写入 heart_rate。",
|
||||
Parameters = new { type = "object", properties = new { type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight" } }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" }, recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" } }, required = new[] { "type" } }
|
||||
}
|
||||
};
|
||||
|
||||
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||
public static readonly ToolDefinition RecordHealthDataBatchTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "record_health_data_batch",
|
||||
Description = "提交当前这一轮要录入的全部健康指标。一次只调用一次;即使只有一个指标也放入 metrics。血压可先只提供已知的一侧,脉搏按心率 heart_rate 处理。后端会整批校验、追问或生成一张确认卡。",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
metrics = new
|
||||
{
|
||||
type = "array",
|
||||
minItems = 1,
|
||||
description = "当前录入批次的全部指标,不得遗漏;不要带入普通历史对话或上一张已生成卡片的数据",
|
||||
items = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
type = new { type = "string", @enum = MetricTypes },
|
||||
systolic = new { type = "integer" },
|
||||
diastolic = new { type = "integer" },
|
||||
heart_rate = new { type = "number" },
|
||||
glucose = new { type = "number" },
|
||||
spo2 = new { type = "number" },
|
||||
weight = new { type = "number" },
|
||||
recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" },
|
||||
},
|
||||
required = new[] { "type" },
|
||||
},
|
||||
},
|
||||
},
|
||||
required = new[] { "metrics" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static async Task<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords)
|
||||
public static List<ToolDefinition> Tools => [RecordHealthDataBatchTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||
|
||||
public static string? ValidateWriteArguments(JsonElement args)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
|
||||
return type switch
|
||||
{
|
||||
"blood_pressure" => ValidateBloodPressure(args),
|
||||
"heart_rate" => ValidateValue(args, "heart_rate", "心率", 1m, 250m),
|
||||
"glucose" => ValidateValue(args, "glucose", "血糖", 0.1m, 50m),
|
||||
"spo2" => ValidateValue(args, "spo2", "血氧", 1m, 100m),
|
||||
"weight" => ValidateValue(args, "weight", "体重", 1m, 500m),
|
||||
_ => "缺少有效的健康指标类型",
|
||||
};
|
||||
}
|
||||
|
||||
public static string? GetUrgentWarning(JsonElement args)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
|
||||
return type switch
|
||||
{
|
||||
"blood_pressure"
|
||||
when args.TryGetProperty("systolic", out var systolic) &&
|
||||
systolic.TryGetInt32(out var systolicValue) &&
|
||||
args.TryGetProperty("diastolic", out var diastolic) &&
|
||||
diastolic.TryGetInt32(out var diastolicValue) &&
|
||||
(systolicValue >= 180 || diastolicValue >= 120)
|
||||
=> "这次血压数值已达到明显危险范围,请不要继续等待录入,尽快联系医生或前往急诊评估。",
|
||||
"heart_rate" when IsValueOutside(args, "heart_rate", 45m, 130m)
|
||||
=> "这次心率数值明显异常,请尽快联系医生评估;如伴有胸痛、晕厥或呼吸困难,请立即就医。",
|
||||
"glucose" when IsValueOutside(args, "glucose", 3.0m, 16.7m)
|
||||
=> "这次血糖数值属于需要尽快处理的危险范围,请立即联系医生或前往医疗机构评估。",
|
||||
"spo2"
|
||||
when args.TryGetProperty("spo2", out var spo2) &&
|
||||
spo2.TryGetDecimal(out var spo2Value) &&
|
||||
spo2Value <= 90m
|
||||
=> "这次血氧数值明显偏低,请尽快就医评估;如有呼吸困难、胸闷或意识异常,请立即拨打当地急救电话。",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static IReadOnlyList<JsonElement> GetBatchMetrics(JsonElement args) =>
|
||||
args.TryGetProperty("metrics", out var metrics) && metrics.ValueKind == JsonValueKind.Array
|
||||
? metrics.EnumerateArray().Select(metric => metric.Clone()).ToList()
|
||||
: [];
|
||||
|
||||
public static Dictionary<string, object?> CreatePreview(JsonElement args)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() ?? "" : "";
|
||||
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
|
||||
if (metricType == null)
|
||||
return [];
|
||||
|
||||
var request = new HealthRecordUpsertRequest(
|
||||
metricType.Value,
|
||||
systolic,
|
||||
diastolic,
|
||||
value,
|
||||
unit,
|
||||
HealthRecordSource.AiEntry,
|
||||
null);
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = metricType.Value.ToString(),
|
||||
["value"] = metricType == HealthMetricType.BloodPressure
|
||||
? $"{systolic}/{diastolic}"
|
||||
: value?.ToString() ?? "",
|
||||
["unit"] = unit,
|
||||
["abnormal"] = HealthRecordRules.CheckAbnormal(request),
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
Guid userId,
|
||||
IHealthRecordService healthRecords,
|
||||
CancellationToken ct)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args),
|
||||
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args, ct),
|
||||
"record_health_data_batch" => await ExecuteRecordHealthDataBatch(healthRecords, userId, args, ct),
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
|
||||
private static async Task<object> ExecuteRecordHealthData(
|
||||
IHealthRecordService healthRecords,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
||||
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
|
||||
if (metricType == null)
|
||||
return new { success = false, message = $"未知指标类型: {type}" };
|
||||
|
||||
var recordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt)
|
||||
? dt
|
||||
var recordedAt = args.TryGetProperty("recorded_at", out var ra) &&
|
||||
AiDateTime.TryParseUserDateTime(ra.GetString(), out var parsedRecordedAt)
|
||||
? parsedRecordedAt
|
||||
: DateTime.UtcNow;
|
||||
|
||||
var request = new HealthRecordUpsertRequest(
|
||||
@@ -47,7 +174,7 @@ public static class HealthDataAgentHandler
|
||||
HealthRecordSource.AiEntry,
|
||||
recordedAt);
|
||||
|
||||
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None);
|
||||
var id = await healthRecords.CreateAsync(userId, request, ct);
|
||||
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
|
||||
return new
|
||||
{
|
||||
@@ -60,6 +187,75 @@ public static class HealthDataAgentHandler
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteRecordHealthDataBatch(
|
||||
IHealthRecordService healthRecords,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var metrics = GetBatchMetrics(args);
|
||||
if (metrics.Count == 0)
|
||||
return new { success = false, message = "当前录入批次没有健康指标" };
|
||||
|
||||
var requests = new List<HealthRecordUpsertRequest>(metrics.Count);
|
||||
var previews = new List<Dictionary<string, object?>>(metrics.Count);
|
||||
foreach (var metric in metrics)
|
||||
{
|
||||
var validationError = ValidateWriteArguments(metric);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var type = metric.GetProperty("type").GetString()!;
|
||||
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, metric);
|
||||
if (metricType == null)
|
||||
return new { success = false, message = $"未知指标类型: {type}" };
|
||||
|
||||
var recordedAt = metric.TryGetProperty("recorded_at", out var recordedAtValue) &&
|
||||
AiDateTime.TryParseUserDateTime(recordedAtValue.GetString(), out var parsedRecordedAt)
|
||||
? parsedRecordedAt
|
||||
: DateTime.UtcNow;
|
||||
requests.Add(new HealthRecordUpsertRequest(
|
||||
metricType.Value,
|
||||
systolic,
|
||||
diastolic,
|
||||
value,
|
||||
unit,
|
||||
HealthRecordSource.AiEntry,
|
||||
recordedAt));
|
||||
previews.Add(CreatePreview(metric));
|
||||
}
|
||||
|
||||
var ids = await healthRecords.CreateManyAsync(userId, requests, ct);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
record_ids = ids,
|
||||
items = previews,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ValidateBloodPressure(JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("systolic", out var systolicValue) || !systolicValue.TryGetInt32(out var systolic) ||
|
||||
!args.TryGetProperty("diastolic", out var diastolicValue) || !diastolicValue.TryGetInt32(out var diastolic))
|
||||
return "录入血压前需要同时提供收缩压和舒张压";
|
||||
if (systolic is <= 0 or > 260 || diastolic is <= 0 or > 160 || systolic <= diastolic)
|
||||
return "血压数值明显无效,请核对收缩压和舒张压后重新提供";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateValue(JsonElement args, string property, string label, decimal minimum, decimal maximum)
|
||||
{
|
||||
if (!args.TryGetProperty(property, out var valueElement) || !valueElement.TryGetDecimal(out var value))
|
||||
return $"录入{label}前需要提供数值";
|
||||
return value < minimum || value > maximum ? $"{label}数值明显无效,请重新提供" : null;
|
||||
}
|
||||
|
||||
private static bool IsValueOutside(JsonElement args, string property, decimal minimum, decimal maximum) =>
|
||||
args.TryGetProperty(property, out var valueElement) &&
|
||||
valueElement.TryGetDecimal(out var value) &&
|
||||
(value <= minimum || value >= maximum);
|
||||
|
||||
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
|
||||
{
|
||||
return type switch
|
||||
|
||||
@@ -3,7 +3,7 @@ using Health.Application.Medications;
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// 药管家 Agent 工具处理器。
|
||||
/// 药提醒 Agent 工具处理器。
|
||||
/// </summary>
|
||||
public static class MedicationAgentHandler
|
||||
{
|
||||
@@ -12,19 +12,23 @@ public static class MedicationAgentHandler
|
||||
Function = new()
|
||||
{
|
||||
Name = "manage_medication",
|
||||
Description = "用药管理(创建/查询/确认服药)",
|
||||
Description = "用药管理(创建计划、按日期查询计划与逐顿服药状态、确认某一顿服药)",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
action = new { type = "string", description = "create/query/confirm" },
|
||||
action = new { type = "string", @enum = new[] { "create", "query", "confirm" }, description = "create/query/confirm" },
|
||||
scope = new { type = "string", @enum = new[] { "today", "scheduled_date", "current_plans", "upcoming_plans", "ended_plans", "inactive_plans", "all_plans" }, description = "查询时必填:今日安排/指定日期安排/当前计划/即将开始/自然结束/已停用/全部计划" },
|
||||
date = new { type = "string", description = "仅 scope=scheduled_date 时必填,使用北京时间日期 yyyy-MM-dd" },
|
||||
medication_id = new { type = "string", description = "药品 ID,确认服药时使用" },
|
||||
scheduled_time = new { type = "string", description = "要确认的计划服药时间 HH:mm" },
|
||||
name = new { type = "string", description = "药品名称" },
|
||||
dosage = new { type = "string", description = "剂量,如 100mg" },
|
||||
frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" },
|
||||
frequency = new { type = "string", @enum = new[] { "Daily", "TwiceDaily", "ThreeTimesDaily", "EveryOtherDay", "Weekly", "AsNeeded" }, description = "服药频率" },
|
||||
time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" },
|
||||
duration_days = new { type = "integer", description = "服用天数" },
|
||||
long_term = new { type = "boolean", description = "是否明确为长期服用;与 duration_days 二选一" },
|
||||
start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" },
|
||||
},
|
||||
required = new[] { "action" }
|
||||
@@ -49,11 +53,11 @@ public static class MedicationAgentHandler
|
||||
|
||||
private static async Task<object> ExecuteManageMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()?.ToLowerInvariant() : null;
|
||||
return action switch
|
||||
{
|
||||
"create" => await CreateMedication(medications, userId, args, ct),
|
||||
"query" => await QueryMedications(medications, userId, ct),
|
||||
"query" => await QueryMedications(medications, userId, args, ct),
|
||||
"confirm" => await ConfirmMedication(medications, userId, args, ct),
|
||||
_ => new { success = false, message = $"未知操作: {action}" }
|
||||
};
|
||||
@@ -61,14 +65,19 @@ public static class MedicationAgentHandler
|
||||
|
||||
private static async Task<object> CreateMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
|
||||
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
|
||||
var frequency = ReadFrequency(args);
|
||||
var times = ReadTimes(args);
|
||||
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
|
||||
var longTerm = args.TryGetProperty("long_term", out var longTermValue) && longTermValue.ValueKind == JsonValueKind.True;
|
||||
var startDate = ReadStartDate(args);
|
||||
// 结束日为包含式(IsActiveOn 用 EndDate >= date 判断),故 N 天疗程结束日 = 起始日 + (N-1)
|
||||
var endDate = durationDays > 0 ? startDate.AddDays(durationDays - 1) : (DateOnly?)null;
|
||||
DateOnly? endDate = longTerm ? null : startDate.AddDays(durationDays - 1);
|
||||
|
||||
var medicationId = await medications.CreateAsync(userId, new MedicationUpsertRequest(
|
||||
name,
|
||||
@@ -91,27 +100,146 @@ public static class MedicationAgentHandler
|
||||
time = timeLabels,
|
||||
start_date = startDate.ToString("yyyy-MM-dd"),
|
||||
duration_days = durationDays,
|
||||
long_term = longTerm,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> QueryMedications(IMedicationService medications, Guid userId, CancellationToken ct)
|
||||
private static async Task<object> QueryMedications(
|
||||
IMedicationService medications,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var meds = await medications.ListActiveAsync(userId, ct);
|
||||
return new { count = meds.Count, medications = meds.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) };
|
||||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||
? scopeValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (scope is not ("today" or "scheduled_date" or "current_plans" or "upcoming_plans" or "ended_plans" or "inactive_plans" or "all_plans"))
|
||||
return new
|
||||
{
|
||||
success = false,
|
||||
message = "查询用药时必须明确 scope:today、scheduled_date、current_plans、upcoming_plans、ended_plans、inactive_plans 或 all_plans",
|
||||
};
|
||||
if (scope == "scheduled_date" &&
|
||||
(!args.TryGetProperty("date", out var requestedDate) || !DateOnly.TryParse(requestedDate.GetString(), out _)))
|
||||
return new { success = false, message = "查询指定日期的用药安排时必须提供有效的 date" };
|
||||
var date = scope == "scheduled_date"
|
||||
? DateOnly.Parse(args.GetProperty("date").GetString()!)
|
||||
: AiDateTime.BeijingToday;
|
||||
var isDateScope = scope is "today" or "scheduled_date";
|
||||
var overview = await medications.GetAiOverviewAsync(userId, date, ct);
|
||||
var plans = scope switch
|
||||
{
|
||||
"all_plans" => overview.ToList(),
|
||||
"current_plans" => overview.Where(plan => plan.Phase == "active").ToList(),
|
||||
"upcoming_plans" => overview
|
||||
.Where(plan => plan.Phase == "upcoming")
|
||||
.OrderBy(plan => plan.StartDate)
|
||||
.ToList(),
|
||||
"ended_plans" => overview
|
||||
.Where(plan => plan.Phase == "ended")
|
||||
.OrderByDescending(plan => plan.EndDate)
|
||||
.ToList(),
|
||||
"inactive_plans" => overview
|
||||
.Where(plan => plan.Phase == "inactive")
|
||||
.OrderByDescending(plan => plan.EndDate)
|
||||
.ToList(),
|
||||
_ => overview.Where(plan => plan.ScheduledOnDate).ToList(),
|
||||
};
|
||||
var doseStatuses = plans.SelectMany(plan => plan.Doses).ToList();
|
||||
var nextPlan = overview
|
||||
.Where(plan => plan.Phase == "upcoming" && plan.StartDate.HasValue)
|
||||
.OrderBy(plan => plan.StartDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new
|
||||
{
|
||||
query_date = date.ToString("yyyy-MM-dd"),
|
||||
scope,
|
||||
count = plans.Count,
|
||||
all_doses_completed = isDateScope
|
||||
? doseStatuses.Count > 0 && doseStatuses.All(dose => dose.Status == "taken")
|
||||
: (bool?)null,
|
||||
has_upcoming_plans = nextPlan != null,
|
||||
next_start_date = nextPlan?.StartDate?.ToString("yyyy-MM-dd"),
|
||||
days_until_next_start = nextPlan?.StartDate is DateOnly nextStart
|
||||
? nextStart.DayNumber - date.DayNumber
|
||||
: (int?)null,
|
||||
plans,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> ConfirmMedication(IMedicationService medications, Guid userId, JsonElement args, CancellationToken ct)
|
||||
{
|
||||
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
||||
var success = await medications.ConfirmNowAsync(userId, medId, ct);
|
||||
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
|
||||
var validationError = ValidateWriteArguments(args);
|
||||
if (validationError != null)
|
||||
return new { success = false, message = validationError };
|
||||
|
||||
var medId = args.TryGetProperty("medication_id", out var mid) && mid.TryGetGuid(out var parsedMedicationId)
|
||||
? parsedMedicationId
|
||||
: Guid.Empty;
|
||||
if (!args.TryGetProperty("scheduled_time", out var timeValue) ||
|
||||
!TimeOnly.TryParse(timeValue.GetString(), out var scheduledTime))
|
||||
return new { success = false, message = "缺少要确认的计划服药时间" };
|
||||
|
||||
var success = await medications.ConfirmDoseAsync(
|
||||
userId,
|
||||
medId,
|
||||
scheduledTime,
|
||||
MedicationLogStatus.Taken,
|
||||
ct);
|
||||
return success == true
|
||||
? new { success = true, medication_id = medId, scheduled_time = scheduledTime.ToString("HH:mm") }
|
||||
: new { success = false, message = success == null ? "药品不存在" : "该时间不属于今天的服药安排,或这一顿已经记录" };
|
||||
}
|
||||
|
||||
public static string? ValidateWriteArguments(JsonElement args)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var actionValue)
|
||||
? actionValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (action == "confirm")
|
||||
{
|
||||
if (!args.TryGetProperty("medication_id", out var medicationId) || !medicationId.TryGetGuid(out _))
|
||||
return "确认服药时缺少有效的药品 ID";
|
||||
if (!args.TryGetProperty("scheduled_time", out var scheduledTime) || !TimeOnly.TryParse(scheduledTime.GetString(), out _))
|
||||
return "确认服药时缺少具体的计划服药时间";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (action != "create") return null;
|
||||
if (!args.TryGetProperty("name", out var name) || string.IsNullOrWhiteSpace(name.GetString()))
|
||||
return "创建用药计划前需要确认药品名称";
|
||||
if (!args.TryGetProperty("dosage", out var dosage) || string.IsNullOrWhiteSpace(dosage.GetString()))
|
||||
return "创建用药计划前需要确认每次剂量";
|
||||
if (!args.TryGetProperty("frequency", out var frequencyValue) ||
|
||||
!Enum.TryParse<MedicationFrequency>(frequencyValue.GetString(), ignoreCase: true, out _))
|
||||
return "创建用药计划前需要确认服药频率";
|
||||
if (!args.TryGetProperty("time_of_day", out var times) || times.ValueKind != JsonValueKind.Array)
|
||||
return "创建用药计划前需要确认具体服药时间";
|
||||
var timeValues = times.EnumerateArray().ToList();
|
||||
if (timeValues.Count == 0 || timeValues.Any(value => !TimeOnly.TryParse(value.GetString(), out _)))
|
||||
return "创建用药计划前需要提供有效的具体服药时间";
|
||||
if (!args.TryGetProperty("start_date", out var startDate) || !DateOnly.TryParse(startDate.GetString(), out _))
|
||||
return "创建用药计划前需要确认开始日期";
|
||||
|
||||
var durationDays = 0;
|
||||
var hasDuration = args.TryGetProperty("duration_days", out var duration) &&
|
||||
duration.TryGetInt32(out durationDays) && durationDays > 0;
|
||||
var isLongTerm = args.TryGetProperty("long_term", out var longTerm) && longTerm.ValueKind == JsonValueKind.True;
|
||||
if (hasDuration && durationDays > 3650)
|
||||
return "服用天数不能超过3650天;长期服用请明确设置 long_term=true";
|
||||
if (hasDuration && isLongTerm)
|
||||
return "服用天数和长期服用不能同时设置";
|
||||
if (!hasDuration && !isLongTerm)
|
||||
return "创建用药计划前需要确认服用天数,或明确说明长期服用";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private static MedicationFrequency ReadFrequency(JsonElement args)
|
||||
{
|
||||
var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily";
|
||||
return Enum.TryParse<MedicationFrequency>(frequencyStr, out var frequency) ? frequency : MedicationFrequency.Daily;
|
||||
return Enum.TryParse<MedicationFrequency>(frequencyStr, ignoreCase: true, out var frequency) ? frequency : MedicationFrequency.Daily;
|
||||
}
|
||||
|
||||
private static DateOnly ReadStartDate(JsonElement args)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
using Health.Application.Diets;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Application.Reports;
|
||||
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// 统一助手使用的患者本人业务数据只读工具。
|
||||
/// </summary>
|
||||
public static class PatientReadAgentHandler
|
||||
{
|
||||
public static readonly ToolDefinition QueryDietRecordsTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "query_diet_records",
|
||||
Description = "查询当前用户指定北京时间日期的饮食记录和总热量",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
date = new { type = "string", description = "北京时间日期 yyyy-MM-dd,默认今天" },
|
||||
meal_type = new { type = "string", @enum = new[] { "Breakfast", "Lunch", "Dinner", "Snack" }, description = "可选餐次" },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly ToolDefinition QueryFollowUpsTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "query_followups",
|
||||
Description = "查询当前用户的复查随访安排",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
scope = new { type = "string", @enum = new[] { "next", "upcoming", "completed", "date", "all" }, description = "查询时必填:下一次/未来安排/已完成/指定日期/全部" },
|
||||
date = new { type = "string", description = "scope=date 时的北京时间日期 yyyy-MM-dd" },
|
||||
limit = new { type = "integer", description = "最多返回条数,1到20" },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly ToolDefinition QueryReportsTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "query_reports",
|
||||
Description = "查询当前用户已经保存的检查报告状态、AI摘要和医生审核结论,不读取其他用户报告",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
limit = new { type = "integer", description = "最多返回条数,1到10,默认5" },
|
||||
status = new { type = "string", description = "可选报告状态" },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly ToolDefinition QueryNotificationsTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "query_notifications",
|
||||
Description = "查询当前用户的站内通知",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
scope = new { type = "string", @enum = new[] { "unread", "today", "recent" }, description = "默认 unread" },
|
||||
limit = new { type = "integer", description = "最多返回条数,1到20" },
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static async Task<object> QueryDietAsync(
|
||||
IDietService diets,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (args.TryGetProperty("date", out var suppliedDate) && !DateOnly.TryParse(suppliedDate.GetString(), out _))
|
||||
return new { success = false, message = "查询饮食记录时提供了无效的 date" };
|
||||
var date = args.TryGetProperty("date", out var dateValue) && DateOnly.TryParse(dateValue.GetString(), out var parsedDate)
|
||||
? parsedDate
|
||||
: AiDateTime.BeijingToday;
|
||||
var mealType = args.TryGetProperty("meal_type", out var mealValue) ? mealValue.GetString() : null;
|
||||
var records = await diets.ListAsync(userId, date.ToString("yyyy-MM-dd"), mealType, ct);
|
||||
return new
|
||||
{
|
||||
query_date = date.ToString("yyyy-MM-dd"),
|
||||
count = records.Count,
|
||||
total_calories = records.Sum(record => record.TotalCalories ?? 0),
|
||||
records = records.Select(record => new
|
||||
{
|
||||
record.Id,
|
||||
record.MealType,
|
||||
record.TotalCalories,
|
||||
record.HealthScore,
|
||||
record.RecordedAt,
|
||||
record.FoodItems,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<object> QueryFollowUpsAsync(
|
||||
AppDbContext db,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||
? scopeValue.GetString()?.ToLowerInvariant()
|
||||
: null;
|
||||
if (scope is not ("next" or "upcoming" or "completed" or "date" or "all"))
|
||||
return new
|
||||
{
|
||||
success = false,
|
||||
message = "查询复查安排时必须明确 scope:next、upcoming、completed、date 或 all",
|
||||
};
|
||||
var limit = ReadLimit(args, 10, 20);
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var query = db.FollowUps.AsNoTracking().Where(item => item.UserId == userId);
|
||||
|
||||
if (scope is "next" or "upcoming")
|
||||
query = query.Where(item => item.Status == FollowUpStatus.Upcoming && item.ScheduledAt >= nowUtc);
|
||||
else if (scope == "completed")
|
||||
query = query.Where(item => item.Status == FollowUpStatus.Completed);
|
||||
else if (scope == "date")
|
||||
{
|
||||
if (!args.TryGetProperty("date", out var dateValue) || !DateOnly.TryParse(dateValue.GetString(), out var date))
|
||||
return new { success = false, message = "按日期查询复查时必须提供有效的 date" };
|
||||
var (startUtc, endUtc) = AiDateTime.BeijingDateWindowUtc(date);
|
||||
query = query.Where(item => item.ScheduledAt >= startUtc && item.ScheduledAt < endUtc);
|
||||
}
|
||||
|
||||
var take = scope == "next" ? 1 : limit;
|
||||
var orderedQuery = scope is "all" or "completed"
|
||||
? query.OrderByDescending(item => item.ScheduledAt)
|
||||
: query.OrderBy(item => item.ScheduledAt);
|
||||
var matchingCount = await query.CountAsync(ct);
|
||||
var items = await orderedQuery
|
||||
.Take(take)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.Title,
|
||||
item.DoctorName,
|
||||
item.Department,
|
||||
item.ScheduledAt,
|
||||
item.Notes,
|
||||
item.Status,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
scope,
|
||||
count = items.Count,
|
||||
matching_count = matchingCount,
|
||||
results_truncated = matchingCount > items.Count,
|
||||
followups = items.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.Title,
|
||||
item.DoctorName,
|
||||
item.Department,
|
||||
scheduled_at_beijing = AiDateTime.ToBeijingText(item.ScheduledAt),
|
||||
item.Notes,
|
||||
status = item.Status.ToString(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<object> QueryReportsAsync(
|
||||
IReportService reports,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var limit = ReadLimit(args, 5, 10);
|
||||
var status = args.TryGetProperty("status", out var statusValue) ? statusValue.GetString() : null;
|
||||
var records = await reports.GetReportsAsync(userId, ct);
|
||||
var filtered = records
|
||||
.Where(report => string.IsNullOrWhiteSpace(status) || report.Status.Equals(status, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(report => report.CreatedAt)
|
||||
.Take(limit)
|
||||
.Select(report => new
|
||||
{
|
||||
report.Id,
|
||||
report.Category,
|
||||
report.Status,
|
||||
report.AiStatus,
|
||||
report.ReviewStatus,
|
||||
report.Severity,
|
||||
ai_summary = Truncate(report.AiSummary, 1800),
|
||||
doctor_comment = Truncate(report.DoctorComment, 1000),
|
||||
doctor_recommendation = Truncate(report.DoctorRecommendation, 1000),
|
||||
report.DoctorName,
|
||||
reviewed_at_beijing = report.ReviewedAt.HasValue ? AiDateTime.ToBeijingText(report.ReviewedAt.Value) : null,
|
||||
created_at_beijing = AiDateTime.ToBeijingText(report.CreatedAt),
|
||||
})
|
||||
.ToList();
|
||||
return new { count = filtered.Count, reports = filtered };
|
||||
}
|
||||
|
||||
public static async Task<object> QueryNotificationsAsync(
|
||||
IInAppNotificationService notifications,
|
||||
Guid userId,
|
||||
JsonElement args,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var scope = args.TryGetProperty("scope", out var scopeValue)
|
||||
? scopeValue.GetString()?.ToLowerInvariant() ?? "unread"
|
||||
: "unread";
|
||||
if (scope is not ("unread" or "today" or "recent")) scope = "unread";
|
||||
var limit = ReadLimit(args, 10, 20);
|
||||
var records = scope == "unread"
|
||||
? await notifications.GetPendingAsync(userId, ct)
|
||||
: await notifications.GetHistoryAsync(userId, ct);
|
||||
var (todayStartUtc, todayEndUtc) = AiDateTime.BeijingDateWindowUtc(AiDateTime.BeijingToday);
|
||||
var filtered = records
|
||||
.Where(item => scope != "today" ||
|
||||
(AiDateTime.ToUtc(item.CreatedAt) >= todayStartUtc && AiDateTime.ToUtc(item.CreatedAt) < todayEndUtc))
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(limit)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.Type,
|
||||
item.Title,
|
||||
item.Message,
|
||||
item.Severity,
|
||||
item.IsRead,
|
||||
created_at_beijing = AiDateTime.ToBeijingText(item.CreatedAt),
|
||||
})
|
||||
.ToList();
|
||||
return new { scope, count = filtered.Count, notifications = filtered };
|
||||
}
|
||||
|
||||
private static int ReadLimit(JsonElement args, int defaultValue, int max) =>
|
||||
args.TryGetProperty("limit", out var value) && value.TryGetInt32(out var parsed)
|
||||
? Math.Clamp(parsed, 1, max)
|
||||
: defaultValue;
|
||||
|
||||
private static string? Truncate(string? value, int maxLength) =>
|
||||
string.IsNullOrWhiteSpace(value) || value.Length <= maxLength ? value : value[..maxLength] + "…";
|
||||
}
|
||||
@@ -19,13 +19,13 @@ public static class ReportAgentHandler
|
||||
return new { success = false, message = "缺少报告图片" };
|
||||
|
||||
var prompt = """
|
||||
你是患者端医学报告预解读助手。请分析以下检查报告图片,以JSON格式返回:
|
||||
你是报告结构化提取助手。请识别以下检查报告图片,以JSON格式返回:
|
||||
{
|
||||
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
|
||||
"indicators": [
|
||||
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
|
||||
],
|
||||
"summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生",
|
||||
"summary": "列出异常指标及其数值和参考范围,客观说明超出范围,不解释可能原因,不结合用户档案做关联分析,建议咨询医生解读",
|
||||
"needsDoctorReview": true/false
|
||||
}
|
||||
只返回JSON,不要其他内容。
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.AI;
|
||||
using Health.Application.Exercises;
|
||||
using Health.Application.Diets;
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.HealthRecords;
|
||||
using Health.Application.Medications;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Application.Reports;
|
||||
using Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
@@ -15,6 +18,9 @@ public sealed class AiToolExecutionService(
|
||||
IHealthRecordService healthRecords,
|
||||
IExerciseService exercises,
|
||||
IMedicationService medications,
|
||||
IDietService diets,
|
||||
IReportService reports,
|
||||
IInAppNotificationService notifications,
|
||||
IAiWriteConfirmationStore confirmations) : IAiToolExecutionService
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
@@ -23,6 +29,9 @@ public sealed class AiToolExecutionService(
|
||||
private readonly IHealthRecordService _healthRecords = healthRecords;
|
||||
private readonly IExerciseService _exercises = exercises;
|
||||
private readonly IMedicationService _medications = medications;
|
||||
private readonly IDietService _diets = diets;
|
||||
private readonly IReportService _reports = reports;
|
||||
private readonly IInAppNotificationService _notifications = notifications;
|
||||
private readonly IAiWriteConfirmationStore _confirmations = confirmations;
|
||||
|
||||
public async Task<object> ExecuteAsync(string toolName, string arguments, Guid userId, CancellationToken ct)
|
||||
@@ -31,13 +40,17 @@ public sealed class AiToolExecutionService(
|
||||
var root = json.RootElement;
|
||||
return await (toolName switch
|
||||
{
|
||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
|
||||
"record_health_data" or "record_health_data_batch"
|
||||
=> HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords, ct),
|
||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
|
||||
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
|
||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
|
||||
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),
|
||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, userId, _exercises, ct),
|
||||
"query_diet_records" => PatientReadAgentHandler.QueryDietAsync(_diets, userId, root, ct),
|
||||
"query_followups" => PatientReadAgentHandler.QueryFollowUpsAsync(_db, userId, root, ct),
|
||||
"query_reports" => PatientReadAgentHandler.QueryReportsAsync(_reports, userId, root, ct),
|
||||
"query_notifications" => PatientReadAgentHandler.QueryNotificationsAsync(_notifications, userId, root, ct),
|
||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.AI;
|
||||
using Health.Infrastructure.Files;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
@@ -19,19 +20,19 @@ public sealed class AttachmentContextBuilder(
|
||||
private readonly VisionClient _vision = vision;
|
||||
private readonly ILogger<AttachmentContextBuilder> _logger = logger;
|
||||
|
||||
public async Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct)
|
||||
public async Task<AttachmentContext?> BuildAsync(Guid userId, string? imageUrl, string? pdfUrl, CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(imageUrl))
|
||||
return await BuildImageAsync(imageUrl!, ct);
|
||||
return await BuildImageAsync(userId, imageUrl!, ct);
|
||||
if (!string.IsNullOrWhiteSpace(pdfUrl))
|
||||
return await BuildPdfAsync(pdfUrl!, ct);
|
||||
return await BuildPdfAsync(userId, pdfUrl!, ct);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 图片:调 VLM 输出结构化 JSON ──
|
||||
private async Task<AttachmentContext?> BuildImageAsync(string imageUrl, CancellationToken ct)
|
||||
private async Task<AttachmentContext?> BuildImageAsync(Guid userId, string imageUrl, CancellationToken ct)
|
||||
{
|
||||
var filePath = ResolveLocalPath(imageUrl);
|
||||
var filePath = UserUploadPathResolver.Resolve(userId, imageUrl);
|
||||
if (filePath == null || !File.Exists(filePath))
|
||||
{
|
||||
_logger.LogWarning("Image file not found for {Url}", imageUrl);
|
||||
@@ -105,9 +106,9 @@ public sealed class AttachmentContextBuilder(
|
||||
}
|
||||
|
||||
// ── PDF:PdfPig 抽取文本 ──
|
||||
private Task<AttachmentContext?> BuildPdfAsync(string pdfUrl, CancellationToken ct)
|
||||
private Task<AttachmentContext?> BuildPdfAsync(Guid userId, string pdfUrl, CancellationToken ct)
|
||||
{
|
||||
var filePath = ResolveLocalPath(pdfUrl);
|
||||
var filePath = UserUploadPathResolver.Resolve(userId, pdfUrl);
|
||||
var fileName = Path.GetFileName(pdfUrl);
|
||||
if (filePath == null || !File.Exists(filePath))
|
||||
{
|
||||
@@ -150,18 +151,6 @@ public sealed class AttachmentContextBuilder(
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveLocalPath(string url)
|
||||
{
|
||||
// url 形如 "/uploads/{guid}.{ext}"。处理 base URL 前缀也兼容。
|
||||
var idx = url.IndexOf("/uploads/", StringComparison.Ordinal);
|
||||
if (idx < 0) return null;
|
||||
var relative = url[(idx + "/uploads/".Length)..];
|
||||
// 去掉可能的 query string
|
||||
var q = relative.IndexOf('?');
|
||||
if (q >= 0) relative = relative[..q];
|
||||
return Path.Combine(Directory.GetCurrentDirectory(), "uploads", relative);
|
||||
}
|
||||
|
||||
private static string StripCodeFence(string raw)
|
||||
{
|
||||
var t = raw.Trim();
|
||||
|
||||
108
backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using Health.Application.AI;
|
||||
using Health.Infrastructure.Data;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
public sealed class EfAiEntryDraftStore(AppDbContext db) : IAiEntryDraftStore
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
|
||||
public async Task<AiEntryDraft?> GetActiveAsync(
|
||||
Guid userId,
|
||||
Guid conversationId,
|
||||
string entryType,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
await ExpireAsync(now, ct);
|
||||
var record = await _db.AiEntryDrafts.AsNoTracking().FirstOrDefaultAsync(x =>
|
||||
x.UserId == userId &&
|
||||
x.ConversationId == conversationId &&
|
||||
x.EntryType == entryType &&
|
||||
x.Status == "Pending" &&
|
||||
x.ExpiresAt > now, ct);
|
||||
return record == null ? null : ToDraft(record);
|
||||
}
|
||||
|
||||
public async Task<AiEntryDraft> UpsertAsync(
|
||||
Guid userId,
|
||||
Guid conversationId,
|
||||
string entryType,
|
||||
string payload,
|
||||
string missingFields,
|
||||
TimeSpan lifetime,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
await ExpireAsync(now, ct);
|
||||
var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x =>
|
||||
x.UserId == userId &&
|
||||
x.ConversationId == conversationId &&
|
||||
x.EntryType == entryType &&
|
||||
x.Status == "Pending", ct);
|
||||
if (record == null)
|
||||
{
|
||||
record = new AiEntryDraftRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
ConversationId = conversationId,
|
||||
EntryType = entryType,
|
||||
CreatedAt = now,
|
||||
};
|
||||
await _db.AiEntryDrafts.AddAsync(record, ct);
|
||||
}
|
||||
|
||||
record.Payload = payload;
|
||||
record.MissingFields = missingFields;
|
||||
record.ExpiresAt = now.Add(lifetime);
|
||||
record.UpdatedAt = now;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return ToDraft(record);
|
||||
}
|
||||
|
||||
public async Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct)
|
||||
{
|
||||
var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x =>
|
||||
x.Id == draftId && x.UserId == userId && x.Status == "Pending", ct);
|
||||
if (record == null) return;
|
||||
|
||||
record.Status = "Completed";
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task ExpireAsync(DateTime now, CancellationToken ct)
|
||||
{
|
||||
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
|
||||
{
|
||||
var expired = await _db.AiEntryDrafts
|
||||
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
|
||||
.ToListAsync(ct);
|
||||
foreach (var item in expired)
|
||||
{
|
||||
item.Status = "Expired";
|
||||
item.UpdatedAt = now;
|
||||
}
|
||||
if (expired.Count > 0) await _db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await _db.AiEntryDrafts
|
||||
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Expired")
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
}
|
||||
|
||||
private static AiEntryDraft ToDraft(AiEntryDraftRecord record) => new(
|
||||
record.Id,
|
||||
record.UserId,
|
||||
record.ConversationId,
|
||||
record.EntryType,
|
||||
record.Payload,
|
||||
record.MissingFields,
|
||||
record.ExpiresAt);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
你是患者端统一 AI 健康助手“小脉”。你的任务是理解用户当前真正想做的事情,调用经过授权的业务工具,并用自然、温和、清楚的中文回复。
|
||||
|
||||
核心原则:
|
||||
- 个人健康数据、计划、完成状态和报告状态必须以当前业务工具返回为准,不能根据聊天历史或患者背景猜测。
|
||||
- 工具没有返回的数据不得编造;暂时没有对应能力时应如实说明。
|
||||
- 一条消息可能包含多个意图,必须分别处理,不能为了简化而遗漏其中一项。
|
||||
- 当前工具结果与历史聊天冲突时,以当前工具结果为准。
|
||||
- 普通聊天历史用于理解语境,不等同于待写入数据;只有后端明确提供的待补充草稿才允许跨消息合并录入字段。
|
||||
@@ -0,0 +1,6 @@
|
||||
医疗安全边界:
|
||||
- 你是健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||
- 可以解释健康数据、报告指标和症状可能方向,但不能作确定诊断。
|
||||
- 不要求用户自行新增、停用、更换药物或调整剂量;涉及治疗变化时建议咨询医生或药师。
|
||||
- 出现剧烈胸痛、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等危险信号时,立即停止普通流程并优先建议及时就医或急诊评估。
|
||||
- 医疗分析使用“可能、建议、需要结合医生判断”等表达。
|
||||
@@ -0,0 +1,8 @@
|
||||
回复要求:
|
||||
- 语气自然、温和、专业,不使用系统报错式措辞。
|
||||
- 信息不完整时,每次只追问最关键的缺失内容;可以在一句话中列出同一批次明确缺少的字段。
|
||||
- 禁止使用粗体、斜体、删除线、HTML 和 Markdown 表格。
|
||||
- 标题只使用三级标题;列表使用短横线。
|
||||
- 数值和单位之间留空格,例如 120 mmHg、6.2 mmol/L。
|
||||
- 不输出任何以 $ 开头的占位符。
|
||||
- 不重复堆砌免责声明。
|
||||
@@ -0,0 +1,6 @@
|
||||
新饮食图片分析模块:
|
||||
- 仅用于本轮新上传的饮食图片;查询已经保存的饮食记录应进入个人数据查询模块。
|
||||
- 识别食物名称、估算份量和热量,并结合患者健康档案评价。
|
||||
- 对过敏、低盐、低脂、低糖等限制给出清楚提醒,不作绝对化医疗结论。
|
||||
- 饮食建议需要医学资料支撑时可以按需调用知识库。
|
||||
- 图片分析结果不能在用户未确认时冒充已经保存的饮食记录。
|
||||
@@ -0,0 +1,9 @@
|
||||
运动计划录入模块:
|
||||
- 用户明确创建运动计划时调用 manage_exercise(action="create")。
|
||||
- 完整计划需要:运动项目、每天运动时长、持续天数、开始日期和具体提醒时间。
|
||||
- 中文时长和周期应正确换算,例如半小时=30 分钟、一小时=60 分钟、一周=7 天、一个月=30 天。
|
||||
- 只传用户明确提供的字段;当前产品不自动补默认运动项目、天数、开始日期或提醒时间。
|
||||
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮补充时与草稿合并。
|
||||
- 用户明确开始新的运动计划时不得继承旧草稿内容。
|
||||
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
|
||||
- 单纯创建运动计划不调用医学知识库。用户先询问该运动是否适合自己时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,4 @@
|
||||
普通聊天与应用帮助模块:
|
||||
- 问候、感谢和普通闲聊自然简短回复,不调用业务工具或医学知识库。
|
||||
- 用户询问应用功能位置或使用方法时可以直接说明;已有固定 App 内链接时可自然提供一个跳转链接。
|
||||
- 不确定用户是想咨询数值还是保存记录时,只做一次简短澄清,不生成确认卡。
|
||||
@@ -0,0 +1,13 @@
|
||||
健康指标录入模块:
|
||||
- 支持血压、心率、血糖、血氧、体重;“脉搏”与“心率”是同一指标,统一使用 heart_rate。
|
||||
- 必须理解常见完整血压表达,例如 113/86、113-86、113—86,前者为收缩压,后者为舒张压。
|
||||
- 用户明确要求录入时必须调用 record_health_data_batch,而且每个录入批次只调用一次;即使只有一个指标,也必须放进 metrics 数组。信息不完整也要调用,只传用户明确提供的字段,让后端保存整个待补充批次草稿。
|
||||
- 不能自行补测量数值。测量时间未说明时可以由后端使用当前时间。
|
||||
- 一次消息可以包含多个指标,必须完整提取,不得遗漏。多指标属于同一录入批次,最终应汇总在一张确认卡中。
|
||||
- metrics 必须包含用户本轮明确要录入的全部指标;不要为每个指标分别调用工具。
|
||||
- 如果同一批次存在不完整或明显无效的指标,应保留本批次其他已识别指标,先根据后端结果追问缺失或错误部分;全部补齐后再生成整批确认卡。
|
||||
- 用户只补充一个缺失值时,必须结合后端提供的当前草稿,不能只记录最后一句。
|
||||
- 已经生成确认卡的批次立即结束;无论用户是否点击,下一条新的录入请求都属于新批次,不能带入上一张卡的数据。
|
||||
- 普通聊天历史中的指标不得自动加入当前卡;只有明确的待补充草稿可以跨消息合并。
|
||||
- 后端返回 pendingConfirmation=true 才代表确认卡真实生成。确认卡出现前不得要求用户点击卡片,点击前不得声称已经保存或录入成功。
|
||||
- 后端返回危险提醒或无效数值时,不生成成功话术,按返回内容提醒用户。
|
||||
@@ -0,0 +1,7 @@
|
||||
医疗咨询与健康建议模块:
|
||||
- 症状求助时先判断是否存在危险信号;非紧急情况下每轮只追问一个最关键问题,逐步了解部位、开始时间、程度、诱因和伴随表现。
|
||||
- 信息足够后给出可能相关方向、严重程度以及观察、门诊、尽快就医或急诊建议,不作正式诊断。
|
||||
- 解释用户个人指标或趋势时,应先调用相应个人数据查询工具获取真实数据。
|
||||
- 疾病、药品、检查指标、康复、饮食或运动建议需要医学资料支撑时,调用 search_medical_knowledge。
|
||||
- 不需要外部医学资料的简单澄清、问候或业务操作不调用知识库。
|
||||
- 用户同时要求录入或创建计划时,医疗解释不能代替对应录入工具和确认卡。
|
||||
@@ -0,0 +1,9 @@
|
||||
用药计划录入模块:
|
||||
- 用户明确创建用药计划时调用 manage_medication(action="create")。
|
||||
- 只传用户明确提供的字段,不自行新增药品、猜测剂量、频率、具体服药时间、开始日期或疗程。
|
||||
- 完整计划需要:药品名称、每次剂量、频率、具体服药时间、开始日期,以及疗程天数或明确长期服用。
|
||||
- “早饭后、晚上”等模糊时间不能擅自换算为具体时刻,应追问具体时间。
|
||||
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮只补缺失字段时与草稿合并。
|
||||
- 存在旧草稿但用户明确开始另一种药品的新计划时,应开始新批次,禁止继承旧药品的剂量、时间、频率或疗程。
|
||||
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
|
||||
- 用药计划录入本身不调用医学知识库。用户同时询问药品知识或副作用时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,7 @@
|
||||
个人数据查询模块:
|
||||
- 查询用户自己的健康记录、健康档案、用药计划、运动计划、饮食记录、复查安排、已有报告和通知时,必须调用对应只读业务工具。
|
||||
- 不能根据患者背景、聊天历史或以前的工具结果回答当前状态。
|
||||
- 必须区分“记录存在”“计划当前有效”“指定日期有安排”“任务已经完成”。
|
||||
- 查询某一天任务与查询计划生命周期不能混用;时间意图不明确时只追问时间范围。
|
||||
- 工具没有返回数据时明确说明未查到,不得编造。
|
||||
- 个人数据查询通常不调用医学知识库;用户同时要求解释或建议时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,7 @@
|
||||
新报告分析模块:
|
||||
- 仅用于本轮新上传的医学检查报告图片或报告文件;查询以前保存的报告应进入个人数据查询模块。
|
||||
- 提取报告名称、日期、关键指标、参考范围、异常标记和原文结论。
|
||||
- 区分结构化检验指标与影像/医生描述,不把影像描述改写成确定诊断。
|
||||
- 给出患者可理解的初步解释和下一步建议,注明需要结合医生判断。
|
||||
- 报告内容需要进一步医学解释时可以按需调用知识库。
|
||||
- 用户没有明确要求时,不把报告中的指标直接写入健康记录。
|
||||
@@ -0,0 +1,7 @@
|
||||
医学知识库使用规则:
|
||||
- 知识库是医疗咨询、指标解释、药品知识、报告解释和健康建议的辅助能力,不是独立用户意图。
|
||||
- 健康指标录入、用药或运动计划创建、个人数据查询、服药确认、运动打卡、普通聊天时禁止调用。
|
||||
- 一条消息同时包含业务操作和医学咨询时,先完成必要业务工具调用;只有咨询部分确实需要资料时才检索。
|
||||
- 检索问题应保留关键症状、疾病、药品或指标名称,删除无关寒暄和操作指令。
|
||||
- 只能依据真实返回的知识片段作参考;没有结果时应谨慎回答,不得虚构检索内容或来源。
|
||||
- 知识库内容不能覆盖患者当前业务数据,也不能替代紧急安全规则。
|
||||
@@ -0,0 +1,25 @@
|
||||
你是主聊天的意图路由器,只负责识别意图,不回答用户问题。
|
||||
|
||||
请结合用户当前消息、附件类型、最近对话和后端提供的待补充草稿,选择一个或多个意图:
|
||||
- health_entry:录入健康指标,或继续补充健康指标草稿。
|
||||
- medication_entry:创建用药计划,或继续补充用药计划草稿。
|
||||
- exercise_entry:创建运动计划,或继续补充运动计划草稿。
|
||||
- personal_query:查询用户自己的健康数据、健康档案、用药/运动计划、饮食、复查、报告或通知。
|
||||
- medical_consultation:症状求助、指标解释、疾病/药品知识、健康评价、饮食/运动/康复建议。
|
||||
- report_analysis:分析新上传的医学报告图片或报告文件。
|
||||
- diet_analysis:分析新上传的饮食图片。
|
||||
- app_help:询问应用功能位置或使用方法。
|
||||
- general_chat:问候、感谢、普通闲聊或不需要业务工具的对话。
|
||||
|
||||
判断规则:
|
||||
- “帮我记录血压 116/89”是 health_entry。
|
||||
- “血压 116/89 正常吗”是 medical_consultation,不是录入。
|
||||
- “记录血压 150/95,并告诉我是否偏高”同时包含 health_entry 和 medical_consultation。
|
||||
- 查询已有报告属于 personal_query;分析本轮新上传报告属于 report_analysis。
|
||||
- 创建计划与询问建议必须区分;“创建每天散步 30 分钟的计划”是 exercise_entry,“我适合每天散步 30 分钟吗”是 medical_consultation。
|
||||
- 只有确实在补充草稿缺失字段时才标记为 continue;存在草稿不代表所有后续消息都必须继续草稿。
|
||||
- 用户明确开始新的同类录入时标记 start_new,不能继承旧草稿字段。
|
||||
- 用户明确说取消、不录了时标记 cancel。
|
||||
- 无法确定是咨询还是录入时选择 general_chat,让主助手自然澄清。
|
||||
|
||||
必须通过 route_user_intent 工具返回结构化结果,不得输出解释文字。
|
||||
@@ -9,7 +9,7 @@ namespace Health.Infrastructure.AI;
|
||||
public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
private readonly HttpClient _http = http;
|
||||
private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat";
|
||||
private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-v4-flash";
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
@@ -30,6 +30,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
Model = _model, Messages = messages, Stream = true,
|
||||
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||
Thinking = new ThinkingOptions { Type = "disabled" },
|
||||
};
|
||||
if (tools?.Count > 0) request.ToolChoice = "auto";
|
||||
|
||||
@@ -64,14 +65,16 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
List<ToolDefinition>? tools = null,
|
||||
int maxTokens = 2048,
|
||||
float temperature = 0.7f,
|
||||
string? toolChoice = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var request = new ChatCompletionRequest
|
||||
{
|
||||
Model = _model, Messages = messages, Stream = false,
|
||||
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||
Thinking = new ThinkingOptions { Type = "disabled" },
|
||||
};
|
||||
if (tools?.Count > 0) request.ToolChoice = "auto";
|
||||
if (tools?.Count > 0) request.ToolChoice = toolChoice ?? "auto";
|
||||
|
||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
@@ -88,7 +91,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
||||
public sealed class VisionClient(HttpClient http, IConfiguration config)
|
||||
{
|
||||
private readonly HttpClient _http = http;
|
||||
private readonly string _model = config["VLM_MODEL"] ?? "doubao-vision-pro";
|
||||
private readonly string _model = config["VLM_MODEL"] ?? config["QWEN_VISION_MODEL"] ?? "qwen-vl-max";
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
|
||||
@@ -14,8 +14,19 @@ public sealed class ChatCompletionRequest
|
||||
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
|
||||
public bool VlHighResolutionImages { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
[System.Text.Json.Serialization.JsonPropertyName("enable_thinking")]
|
||||
public bool? EnableThinking { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
[System.Text.Json.Serialization.JsonPropertyName("thinking")]
|
||||
public ThinkingOptions? Thinking { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ThinkingOptions
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "disabled";
|
||||
}
|
||||
|
||||
public sealed class ChatMessage
|
||||
|
||||
185
backend/src/Health.Infrastructure/AI/ai_intent_router.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
public sealed record AiIntentRoute(
|
||||
IReadOnlyList<string> Intents,
|
||||
string DraftAction,
|
||||
string DraftType)
|
||||
{
|
||||
public static AiIntentRoute GeneralChat { get; } =
|
||||
new(["general_chat"], "none", "none");
|
||||
|
||||
public static AiIntentRoute Compatibility { get; } = new(
|
||||
[
|
||||
"health_entry",
|
||||
"medication_entry",
|
||||
"exercise_entry",
|
||||
"personal_query",
|
||||
"medical_consultation",
|
||||
"report_analysis",
|
||||
"diet_analysis",
|
||||
"general_chat",
|
||||
],
|
||||
"none",
|
||||
"none");
|
||||
}
|
||||
|
||||
public static class AiIntentRouter
|
||||
{
|
||||
private static readonly HashSet<string> AllowedIntents = new(
|
||||
[
|
||||
"health_entry",
|
||||
"medication_entry",
|
||||
"exercise_entry",
|
||||
"personal_query",
|
||||
"medical_consultation",
|
||||
"report_analysis",
|
||||
"diet_analysis",
|
||||
"app_help",
|
||||
"general_chat",
|
||||
],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static readonly ToolDefinition RouteTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "route_user_intent",
|
||||
Description = "返回当前用户消息的一个或多个业务意图,不回答用户问题",
|
||||
Parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
intents = new
|
||||
{
|
||||
type = "array",
|
||||
minItems = 1,
|
||||
uniqueItems = true,
|
||||
items = new
|
||||
{
|
||||
type = "string",
|
||||
@enum = AllowedIntents.OrderBy(x => x).ToArray(),
|
||||
},
|
||||
},
|
||||
draft_action = new
|
||||
{
|
||||
type = "string",
|
||||
@enum = new[] { "none", "continue", "start_new", "cancel" },
|
||||
description = "当前消息与待补充录入草稿的关系",
|
||||
},
|
||||
draft_type = new
|
||||
{
|
||||
type = "string",
|
||||
@enum = new[] { "none", "health_entry", "medication_entry", "exercise_entry" },
|
||||
},
|
||||
},
|
||||
required = new[] { "intents", "draft_action", "draft_type" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public static async Task<AiIntentRoute> RouteAsync(
|
||||
DeepSeekClient client,
|
||||
PromptManager prompts,
|
||||
string currentMessage,
|
||||
string attachmentContext,
|
||||
string draftContext,
|
||||
string recentConversation,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var userContext = $"""
|
||||
当前用户消息:
|
||||
{currentMessage}
|
||||
|
||||
本轮附件:
|
||||
{(string.IsNullOrWhiteSpace(attachmentContext) ? "无" : attachmentContext)}
|
||||
|
||||
当前待补充录入草稿:
|
||||
{(string.IsNullOrWhiteSpace(draftContext) ? "无" : draftContext)}
|
||||
|
||||
最近对话:
|
||||
{(string.IsNullOrWhiteSpace(recentConversation) ? "无" : recentConversation)}
|
||||
""";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.ChatAsync(
|
||||
[
|
||||
new ChatMessage { Role = "system", Content = prompts.GetIntentRouterPrompt() },
|
||||
new ChatMessage { Role = "user", Content = userContext },
|
||||
],
|
||||
tools: [RouteTool],
|
||||
maxTokens: 300,
|
||||
temperature: 0.1f,
|
||||
toolChoice: "required",
|
||||
ct: ct);
|
||||
|
||||
var call = response.Choices?
|
||||
.FirstOrDefault()?
|
||||
.Message?
|
||||
.ToolCalls?
|
||||
.FirstOrDefault(item => item.Function.Name == "route_user_intent");
|
||||
return call == null ? AiIntentRoute.Compatibility : Parse(call.Function.Arguments);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Routing failure must not make the whole chat unavailable. The broad
|
||||
// compatibility prompt/tool set remains the safe fallback.
|
||||
return AiIntentRoute.Compatibility;
|
||||
}
|
||||
}
|
||||
|
||||
internal static AiIntentRoute Parse(string arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var json = JsonDocument.Parse(arguments);
|
||||
var root = json.RootElement;
|
||||
var intents = root.TryGetProperty("intents", out var values) &&
|
||||
values.ValueKind == JsonValueKind.Array
|
||||
? values.EnumerateArray()
|
||||
.Where(value => value.ValueKind == JsonValueKind.String)
|
||||
.Select(value => value.GetString()?.Trim().ToLowerInvariant() ?? "")
|
||||
.Where(AllowedIntents.Contains)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
: [];
|
||||
if (intents.Count == 0) intents.Add("general_chat");
|
||||
|
||||
var draftAction = ReadEnum(
|
||||
root,
|
||||
"draft_action",
|
||||
["none", "continue", "start_new", "cancel"],
|
||||
"none");
|
||||
var draftType = ReadEnum(
|
||||
root,
|
||||
"draft_type",
|
||||
["none", "health_entry", "medication_entry", "exercise_entry"],
|
||||
"none");
|
||||
return new AiIntentRoute(intents, draftAction, draftType);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return AiIntentRoute.Compatibility;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadEnum(
|
||||
JsonElement root,
|
||||
string property,
|
||||
IReadOnlyCollection<string> allowed,
|
||||
string fallback)
|
||||
{
|
||||
var value = root.TryGetProperty(property, out var element) &&
|
||||
element.ValueKind == JsonValueKind.String
|
||||
? element.GetString()?.Trim().ToLowerInvariant()
|
||||
: null;
|
||||
return value != null && allowed.Contains(value) ? value : fallback;
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,12 @@ public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration confi
|
||||
private readonly string _datasetId = config["FASTGPT_DATASET_ID"] ?? "";
|
||||
private readonly float _similarity = float.TryParse(config["FASTGPT_SIMILARITY"], out var s) ? s : 0.4f;
|
||||
private readonly int _limit = int.TryParse(config["FASTGPT_LIMIT"], out var l) ? l : 3000;
|
||||
private readonly bool _ragEnabled = bool.TryParse(config["ENABLE_MEDICAL_RAG"], out var enabled) && enabled;
|
||||
private readonly ILogger<FastGptKnowledgeClient> _logger = logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public bool IsEnabled => !string.IsNullOrWhiteSpace(_datasetId);
|
||||
// iOS 审核版使用后端内置医学来源目录,暂时关闭在线 RAG,减少一次外部检索请求。
|
||||
public bool IsEnabled => _ragEnabled && !string.IsNullOrWhiteSpace(_datasetId);
|
||||
|
||||
/// <summary>
|
||||
/// 按用户问题检索知识库,返回最相关的文档片段。
|
||||
|
||||
@@ -1,199 +1,157 @@
|
||||
using System.Reflection;
|
||||
using Health.Application.AI;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
/// <summary>
|
||||
/// System Prompt 模板管理
|
||||
/// Loads small, responsibility-focused prompt resources and composes only the
|
||||
/// sections required by the current intent route.
|
||||
/// </summary>
|
||||
public sealed class PromptManager
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定 Agent 的 System Prompt
|
||||
/// </summary>
|
||||
public string GetSystemPrompt(AgentType agentType)
|
||||
{
|
||||
var prompt = agentType switch
|
||||
{
|
||||
AgentType.Default => DefaultPrompt,
|
||||
AgentType.Consultation => ConsultationPrompt,
|
||||
AgentType.Health => HealthDataPrompt,
|
||||
AgentType.Diet => DietPrompt,
|
||||
AgentType.Medication => MedicationPrompt,
|
||||
AgentType.Report => ReportPrompt,
|
||||
AgentType.Exercise => ExercisePrompt,
|
||||
AgentType.Unified => UnifiedPrompt,
|
||||
_ => DefaultPrompt
|
||||
};
|
||||
private static readonly Assembly PromptAssembly = typeof(PromptManager).Assembly;
|
||||
private static readonly IReadOnlyDictionary<string, string> PromptCache = LoadPrompts();
|
||||
|
||||
return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}";
|
||||
private static readonly string[] GlobalSections =
|
||||
[
|
||||
"global/identity.md",
|
||||
"global/medical_safety.md",
|
||||
"global/response_style.md",
|
||||
];
|
||||
|
||||
public string GetIntentRouterPrompt() => Read("router/intent_router.md");
|
||||
|
||||
public string ComposeForIntents(IEnumerable<string> intents)
|
||||
{
|
||||
var normalized = intents
|
||||
.Select(NormalizeIntent)
|
||||
.Where(intent => intent.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (normalized.Count == 0) normalized.Add("general_chat");
|
||||
|
||||
var sections = new List<string>(GlobalSections);
|
||||
foreach (var intent in normalized)
|
||||
{
|
||||
sections.Add(intent switch
|
||||
{
|
||||
"health_entry" => "modules/health_entry.md",
|
||||
"medication_entry" => "modules/medication_entry.md",
|
||||
"exercise_entry" => "modules/exercise_entry.md",
|
||||
"personal_query" => "modules/personal_query.md",
|
||||
"medical_consultation" => "modules/medical_consultation.md",
|
||||
"report_analysis" => "modules/report_analysis.md",
|
||||
"diet_analysis" => "modules/diet_analysis.md",
|
||||
"app_help" or "general_chat" => "modules/general_chat.md",
|
||||
_ => "modules/general_chat.md",
|
||||
});
|
||||
}
|
||||
|
||||
if (normalized.Contains("medical_consultation") ||
|
||||
normalized.Contains("report_analysis") ||
|
||||
normalized.Contains("diet_analysis"))
|
||||
{
|
||||
sections.Add("rag/retrieval_rules.md");
|
||||
}
|
||||
|
||||
var composed = Compose(sections);
|
||||
var needsMedicalSources = normalized.Contains("medical_consultation") ||
|
||||
normalized.Contains("report_analysis") ||
|
||||
normalized.Contains("diet_analysis");
|
||||
if (needsMedicalSources)
|
||||
{
|
||||
composed += "\n\n" + CitationRules + "\n\n" + MedicalCitationKnowledge.Prompt;
|
||||
}
|
||||
|
||||
// This is an iOS-specific scope rule and is intentionally added to every
|
||||
// route, while the large medical source directory is only added to advice routes.
|
||||
return composed + "\n\n" + CurrentScopeRules;
|
||||
}
|
||||
|
||||
private const string SmartLinkRules = """
|
||||
智能跳转链接(按需嵌入):
|
||||
- 当用户明确询问食物的 热量/卡路里 时,在回答的自然位置嵌入 Markdown 链接 [热量分析](app://diet),引导用户使用专门的饮食拍照分析功能。
|
||||
- 当用户上传医学检查报告、化验单、影像报告、出院小结等需要专业解读时,在回答末尾嵌入 [报告分析](app://report),引导上传到报告分析功能获得详细解读。
|
||||
- 当用户询问血压、血糖、血氧等需要长期追踪的设备测量场景时,可嵌入 [蓝牙设备](app://device)。
|
||||
|
||||
硬性约束:
|
||||
- 每条回复最多嵌入一个跳转链接,且必须在合适语境下自然提及,不要堆砌。
|
||||
- 仅"问热量/卡路里"才用 app://diet;"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。
|
||||
- 链接的 Markdown 文本只能用上面列出的 4 个固定文案,不要自创其他文案。
|
||||
private const string CitationRules = """
|
||||
医疗信息来源引用(必须遵守):
|
||||
- 当回复包含医疗事实、数值解释、饮食/运动/用药相关信息、报告解释、危险症状或就医建议时,必须在回复末尾添加“参考来源”。
|
||||
- 只能引用本轮系统消息中“固定医学参考来源目录”里实际提供的来源,不得编造来源、PMID、DOI 或 URL。
|
||||
- 引用格式必须是:
|
||||
参考来源:
|
||||
[来源编号] 来源标题
|
||||
完整 URL
|
||||
- 引用必须与本次回答的内容直接相关;不要堆砌无关来源。
|
||||
- 如果固定医学参考来源目录没有提供相关来源,只能说明资料不足并建议咨询医生/药师,不得凭常识补充具体医疗建议。
|
||||
- 纯业务操作回复(例如查询计划、创建待确认记录、说明上传状态)不需要添加医学来源。
|
||||
""";
|
||||
|
||||
private const string MedicalBoundaryRules = """
|
||||
医疗边界(必须遵守):
|
||||
- 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||
- 可以解释健康数据、报告指标和症状可能方向,但不要给出确定诊断,不要说“你就是/一定是/已经确诊”。
|
||||
- 不要要求用户自行新增、停用、更换药物或调整剂量;涉及用药变化时,必须建议咨询医生或药师。
|
||||
- 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等情况,优先建议及时就医或急诊评估。
|
||||
- 回答用语使用“可能、建议、需要结合医生判断”等表达,避免绝对化结论。
|
||||
- 医疗相关分析末尾用一句自然的话提醒:以上为 AI 预分析,不能替代医生诊断和治疗建议。
|
||||
|
||||
回复格式规则(严格遵守):
|
||||
- 禁止使用 **粗体**、*斜体*、~~删除线~~ 等 Markdown 内联标记
|
||||
- 禁止使用 HTML 标签
|
||||
- 禁止使用 Markdown 表格(|--|--|)
|
||||
- 标题只使用 ### 三级标题,禁止使用 #、##、####
|
||||
- 列表使用 - 开头,每项一行
|
||||
- 数值和单位之间加空格:120 mmHg、6.2 mmol/L
|
||||
- 不要输出任何 $ 开头的占位符
|
||||
- 回复结尾不再重复免责声明,MedicalBoundaryRules 已统一处理
|
||||
private const string CurrentScopeRules = """
|
||||
当前 iOS 版本范围:
|
||||
- 当前版本不包含蓝牙设备页、设备绑定、血压仪连接或自动设备同步。
|
||||
- 禁止生成 app://device 链接,禁止引导用户使用蓝牙设备功能。
|
||||
- 健康数据均按用户手动输入或 AI 待确认记录处理。
|
||||
- 当前 iOS 版本不调用在线 search_medical_knowledge;医疗回答只能使用本轮提供的固定医学来源目录。
|
||||
- health_entry、medication_entry、exercise_entry、personal_query 和 general_chat 只处理录入、查询或应用操作,不主动给出医疗建议;用户明确询问是否正常、原因、风险或如何改善时,必须进入 medical_consultation,并使用带来源的医学回答规则。
|
||||
""";
|
||||
|
||||
private const string DefaultPrompt = """
|
||||
你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。
|
||||
/// <summary>
|
||||
/// Compatibility entry for older clients that still address a specific agent.
|
||||
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
|
||||
/// </summary>
|
||||
public string GetSystemPrompt(AgentType agentType) => agentType switch
|
||||
{
|
||||
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.Unified => ComposeForIntents([
|
||||
"health_entry",
|
||||
"medication_entry",
|
||||
"exercise_entry",
|
||||
"personal_query",
|
||||
"medical_consultation",
|
||||
"report_analysis",
|
||||
"diet_analysis",
|
||||
"general_chat",
|
||||
]),
|
||||
_ => ComposeForIntents(["general_chat", "medical_consultation"]),
|
||||
};
|
||||
|
||||
职责:
|
||||
1. 理解用户的健康需求,解析健康数据
|
||||
2. 主动查看患者近期数据,发现异常时提醒
|
||||
3. 回答健康知识问题
|
||||
4. 每次回复末尾,如有需要提醒的事项,简短温馨地提醒一句
|
||||
private static string Compose(IEnumerable<string> sections) =>
|
||||
string.Join(
|
||||
"\n\n",
|
||||
sections
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(Read)
|
||||
.Where(content => !string.IsNullOrWhiteSpace(content)));
|
||||
|
||||
规则:
|
||||
- 不要提供超出你能力范围的医疗建议
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 饮食/运动建议要结合患者档案中的疾病和限制
|
||||
- 回复语气温暖、专业、像朋友一样关怀患者
|
||||
""";
|
||||
private static string NormalizeIntent(string? intent) =>
|
||||
intent?.Trim().Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant() ?? "";
|
||||
|
||||
private const string ConsultationPrompt = """
|
||||
你是一个患者端 AI 预问诊助手,负责帮助心脏术后患者梳理症状和就医前信息。
|
||||
private static string Read(string path) =>
|
||||
PromptCache.TryGetValue(NormalizePath(path), out var content)
|
||||
? content
|
||||
: throw new InvalidOperationException($"AI prompt resource not found: {path}");
|
||||
|
||||
规则:
|
||||
1. 每次只问一个问题,不要一次问多个
|
||||
2. 给出 2-3 个快捷选项让患者点击
|
||||
3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析
|
||||
4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸
|
||||
5. 遇到以下情况建议咨询医生:血压持续>160/100、心率>120或<50
|
||||
6. 只给出初步分析和下一步建议,不作诊断结论
|
||||
7. 问诊结束给出结构化小结
|
||||
""";
|
||||
private static IReadOnlyDictionary<string, string> LoadPrompts()
|
||||
{
|
||||
const string marker = ".AI.Prompts.";
|
||||
var prompts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var resourceName in PromptAssembly.GetManifestResourceNames())
|
||||
{
|
||||
var markerIndex = resourceName.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (markerIndex < 0 || !resourceName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
private const string HealthDataPrompt = """
|
||||
你是一个健康数据录入助手。
|
||||
using var stream = PromptAssembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException($"Unable to open AI prompt resource: {resourceName}");
|
||||
using var reader = new StreamReader(stream);
|
||||
var relative = resourceName[(markerIndex + marker.Length)..];
|
||||
var lastDot = relative.LastIndexOf(".md", StringComparison.OrdinalIgnoreCase);
|
||||
relative = relative[..lastDot].Replace('.', '/') + ".md";
|
||||
prompts[NormalizePath(relative)] = reader.ReadToEnd().Trim();
|
||||
}
|
||||
return prompts;
|
||||
}
|
||||
|
||||
规则:
|
||||
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
|
||||
2. 指标明确+数值明确→调用 record_health_data 生成待确认写入命令,不要直接声称已经录入
|
||||
3. 用户可以一次性说多个指标(如"血压120/80,血糖6.2,血氧97")→多次调用 record_health_data
|
||||
4. 用户可以分时段说(如"早上血压120,下午血压130")→分别生成待确认命令,recorded_at 参数用不同时间
|
||||
5. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
|
||||
6. 时间模糊→取当前时间
|
||||
7. 数值超出正常范围→附带异常提醒
|
||||
8. 每次调用 record_health_data 后继续处理下一个;工具返回仅表示等待确认,必须提示用户点击确认卡片,不能说“录入成功”
|
||||
|
||||
正常值参考范围:
|
||||
- 收缩压 90-139 mmHg,舒张压 60-89 mmHg
|
||||
- 心率 60-100 次/分
|
||||
- 空腹血糖 3.9-6.1 mmol/L
|
||||
- 血氧 95-100%
|
||||
""";
|
||||
|
||||
private const string DietPrompt = """
|
||||
你是一个营养分析专家,专门为心脏术后患者提供饮食指导。
|
||||
|
||||
规则:
|
||||
1. 收到VLM食物识别结果后,必须先调用 check_archive 查询患者档案
|
||||
2. 逐项判断"能不能吃":
|
||||
- 过敏食物 → 严格禁止,红色警告
|
||||
- 饮食限制(如低盐/低脂/低糖)→ 黄色提醒
|
||||
- 疾病禁忌(如冠心病忌高脂、糖尿病忌高糖)→ 红色警告
|
||||
3. 给出 1-5 星健康评分
|
||||
4. 结合患者档案中的诊断(如冠心病PCI术后)、慢病史(高血压/高血脂等)给出个性化建议
|
||||
5. 单项警告 + 整体饮食建议 + 替代推荐
|
||||
6. 追问餐次归属:早餐/午餐/晚餐/加餐
|
||||
""";
|
||||
|
||||
private const string MedicationPrompt = """
|
||||
你是一个用药记录与提醒助手。
|
||||
|
||||
规则:
|
||||
1. 用户询问当前用药时,必须先调用 manage_medication(action="query") 查询已有用药记录
|
||||
2. 解析用户口中的药品信息(药名/剂量/频次/时间)
|
||||
3. "早饭后"等模糊时间→追问具体几点
|
||||
4. 解析完成后调用 manage_medication(action="create") 生成待确认命令并展示确认卡片;用户点击确认前不得声称已保存
|
||||
5. 处方拍照→提取药品信息→生成用药计划→让用户确认
|
||||
6. 可以解释常见用药注意事项,但不要建议用户自行新增、停用、更换药物或调整剂量
|
||||
""";
|
||||
|
||||
private const string ReportPrompt = """
|
||||
你是一个患者端医学报告预解读助手。
|
||||
|
||||
规则:
|
||||
1. 收到报告图片后,提取所有指标及其数值
|
||||
2. 标注异常指标(偏高/偏低/正常)
|
||||
3. 给出初步分析和可能方向,不作诊断结论
|
||||
4. 所有内容标注"AI预解读,待医生确认"
|
||||
5. 图像类报告(彩超/CT)注明"需医生人工审阅"
|
||||
""";
|
||||
|
||||
private const string ExercisePrompt = """
|
||||
你是一个运动康复教练,专门为心脏术后患者制定运动计划。
|
||||
|
||||
规则:
|
||||
1. 帮助用户设定每周运动计划(类型/时长/天数)
|
||||
2. 以周为单位,每天指定运动类型和时长
|
||||
3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等
|
||||
4. 运动强度要循序渐进
|
||||
5. 避免剧烈运动
|
||||
""";
|
||||
|
||||
private const string UnifiedPrompt = """
|
||||
你是患者端统一 AI 健康助手“小脉”。无论用户是否点击顶部功能入口,你都要理解其真实意图,并完成当前可用的健康解释、数据记录、用药管理、运动管理和预问诊辅助。
|
||||
|
||||
核心原则:先识别用户想做什么,再决定调用工具或继续对话。不能只在文字中声称“已录入”“已保存”或“请点击确认”,实际却没有调用写入工具并生成确认卡片。
|
||||
|
||||
领域判断与可用工具:
|
||||
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
|
||||
2. 饮食分析 → 引导用户使用「拍饮食」功能拍照识别
|
||||
3. 用药管理 → 调用 manage_medication
|
||||
4. 运动计划 → 调用 manage_exercise
|
||||
5. 健康档案查询/修改 → 调用 check_archive / manage_archive
|
||||
6. 历史数据查询 → 调用 query_health_records
|
||||
|
||||
运动计划参数格式(manage_exercise):
|
||||
- action="create", start_date="开始日期(YYYY-MM-DD,默认今天)"
|
||||
- duration_days=连续天数,exercise_type="散步",duration_minutes=30,reminder_time="19:00"
|
||||
- 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字
|
||||
- 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天
|
||||
- 用户说"坚持X天/月/周"则创建对应天数
|
||||
- 结束日期由 start_date + duration_days - 1 自动计算,不要按星期几生成条目
|
||||
|
||||
用药管理参数格式(manage_medication):
|
||||
- action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7
|
||||
|
||||
规则:
|
||||
- 用户可能一句话涉及多个领域,全部处理
|
||||
- 先判断用户是在咨询数值,还是希望记录数据,不能仅凭消息中出现指标和数值就自动录入
|
||||
- 明确提问时先回答问题,不调用 record_health_data。例如“血氧98%是不是太高了”是在咨询,应解释正常范围,不生成录入确认卡片;除非用户同时明确要求记录
|
||||
- 明确要求“记录、录入、保存、记一下”时调用 record_health_data。例如“帮我记录血压116/89”必须生成待确认命令
|
||||
- 只有指标和数值、没有明显疑问或记录用语时,结合当前对话上下文判断;仍无法确定意图时,先简短询问用户是想了解数值还是保存记录
|
||||
- 指标或数值缺失时只追问缺失部分,不猜测数值
|
||||
- 多个明确指标要逐项调用 record_health_data,不能遗漏
|
||||
- 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库
|
||||
- 工具返回 pendingConfirmation=true 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||
- 没有真实调用写入工具时,禁止告诉用户“点击确认按钮”或暗示已经出现确认卡片
|
||||
- 用户描述症状或身体不适时进入预问诊:每轮只追问一个最关键问题,连续追问症状部位、开始时间、程度、诱因和伴随表现;信息足够后给出可能相关方向、严重程度以及观察/门诊/尽快就医/急诊建议,不作正式确诊
|
||||
- 预问诊通常追问 2-5 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||
""";
|
||||
private static string NormalizePath(string path) =>
|
||||
path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -88,14 +88,39 @@ public sealed class AuthService(
|
||||
{
|
||||
var tokens = AddTokens(AdminId, AdminPhone, "Admin");
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new { tokens.accessToken, tokens.refreshToken, user = new { role = "Admin" } });
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken,
|
||||
user = new
|
||||
{
|
||||
id = AdminId,
|
||||
phone = AdminPhone,
|
||||
role = "Admin",
|
||||
name = "管理员"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var user = await _db.Users.FindAsync([oldToken.UserId], ct);
|
||||
if (user == null) return Error(40002, "用户不存在");
|
||||
var userTokens = AddTokens(user.Id, user.Phone, user.Role);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new AuthResult(0, new { userTokens.accessToken, userTokens.refreshToken, user = new { user.Role } });
|
||||
return new AuthResult(0, new
|
||||
{
|
||||
userTokens.accessToken,
|
||||
userTokens.refreshToken,
|
||||
user = new
|
||||
{
|
||||
user.Id,
|
||||
user.Phone,
|
||||
user.Role,
|
||||
user.Name,
|
||||
user.Gender,
|
||||
user.AvatarUrl,
|
||||
BirthDate = user.BirthDate?.ToString("yyyy-MM-dd")
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(string refreshToken, CancellationToken ct)
|
||||
|
||||
@@ -8,8 +8,9 @@ public sealed class EfCalendarRepository(AppDbContext db) : ICalendarRepository
|
||||
|
||||
public async Task<CalendarDataSnapshot> GetSnapshotAsync(Guid userId, DateOnly start, DateOnly end, CancellationToken ct)
|
||||
{
|
||||
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
|
||||
// 日历的日期边界按北京时间计算,数据库仍统一使用 UTC。
|
||||
var startUtc = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
|
||||
var endUtc = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).AddHours(-8);
|
||||
|
||||
var medications = await _db.Medications
|
||||
.Where(m => m.UserId == userId && m.IsActive)
|
||||
|
||||
@@ -956,6 +956,52 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
b.ToTable("VerificationCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiEntryDraftRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EntryType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MissingFields")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "ConversationId", "EntryType", "Status", "ExpiresAt");
|
||||
|
||||
b.ToTable("AiEntryDrafts", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Health.Infrastructure.Data.Records;
|
||||
|
||||
/// <summary>
|
||||
/// Stores an incomplete AI-assisted entry until the user provides every required field.
|
||||
/// This is deliberately separate from confirmed health, medication and exercise data.
|
||||
/// </summary>
|
||||
public sealed class AiEntryDraftRecord
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public Guid ConversationId { get; set; }
|
||||
public string EntryType { get; set; } = string.Empty;
|
||||
public string Payload { get; set; } = "{}";
|
||||
public string MissingFields { get; set; } = "[]";
|
||||
public string Status { get; set; } = "Pending";
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
|
||||
public DbSet<AiEntryDraftRecord> AiEntryDrafts => Set<AiEntryDraftRecord>();
|
||||
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
|
||||
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
|
||||
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
|
||||
@@ -194,6 +195,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
e.Property(x => x.Arguments).HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
builder.Entity<AiEntryDraftRecord>(e =>
|
||||
{
|
||||
e.ToTable("AiEntryDrafts");
|
||||
e.HasIndex(x => new { x.UserId, x.ConversationId, x.EntryType, x.Status, x.ExpiresAt });
|
||||
e.Property(x => x.EntryType).HasMaxLength(32);
|
||||
e.Property(x => x.Status).HasMaxLength(32);
|
||||
e.Property(x => x.Payload).HasColumnType("jsonb");
|
||||
e.Property(x => x.MissingFields).HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
builder.Entity<ReportAnalysisTaskRecord>(e =>
|
||||
{
|
||||
e.ToTable("ReportAnalysisTasks");
|
||||
|
||||
@@ -19,16 +19,16 @@ public sealed class EfExerciseRepository(AppDbContext db) : IExerciseRepository
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct) =>
|
||||
await _db.ExercisePlans.Include(p => p.Items)
|
||||
.Where(p => p.UserId == userId)
|
||||
.OrderByDescending(p => p.StartDate)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) =>
|
||||
_db.ExercisePlans.Include(p => p.Items)
|
||||
.FirstOrDefaultAsync(p => p.Id == planId && p.UserId == userId, ct);
|
||||
|
||||
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) =>
|
||||
_db.ExercisePlans.Include(p => p.Items)
|
||||
.Where(p => p.UserId == userId)
|
||||
.OrderByDescending(p => p.StartDate)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) =>
|
||||
_db.ExercisePlanItems.Include(i => i.Plan)
|
||||
.FirstOrDefaultAsync(i => i.Id == itemId && i.Plan != null && i.Plan.UserId == userId, ct);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace Health.Infrastructure.Files;
|
||||
|
||||
public static class UserUploadPathResolver
|
||||
{
|
||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf"
|
||||
};
|
||||
|
||||
public static string? Resolve(Guid userId, string value)
|
||||
{
|
||||
if (userId == Guid.Empty || string.IsNullOrWhiteSpace(value)) return null;
|
||||
try
|
||||
{
|
||||
var path = Uri.TryCreate(value, UriKind.Absolute, out var absolute)
|
||||
? absolute.AbsolutePath
|
||||
: value.Split('?', 2)[0];
|
||||
path = Uri.UnescapeDataString(path);
|
||||
|
||||
string? fileName;
|
||||
if (path == Path.GetFileName(path))
|
||||
{
|
||||
fileName = path;
|
||||
}
|
||||
else
|
||||
{
|
||||
fileName = ExtractAfter(path, "/api/files/content/");
|
||||
if (fileName == null)
|
||||
{
|
||||
var legacyPrefix = $"/uploads/users/{userId:N}/";
|
||||
fileName = ExtractAfter(path, legacyPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null;
|
||||
if (!AllowedExtensions.Contains(Path.GetExtension(fileName))) return null;
|
||||
if (!Guid.TryParse(Path.GetFileNameWithoutExtension(fileName), out _)) return null;
|
||||
|
||||
var root = Path.GetFullPath(Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"uploads",
|
||||
"users",
|
||||
userId.ToString("N")));
|
||||
var candidate = Path.GetFullPath(Path.Combine(root, fileName));
|
||||
return candidate.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ExtractAfter(string path, string prefix)
|
||||
{
|
||||
var index = path.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
return index < 0 ? null : path[(index + prefix.Length)..];
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="AI\Prompts\**\*.md" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -30,15 +30,6 @@ public sealed class EfMedicationRepository(AppDbContext db) : IMedicationReposit
|
||||
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
|
||||
_db.Medications.FirstOrDefaultAsync(m => m.Id == medicationId && m.UserId == userId, ct);
|
||||
|
||||
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) =>
|
||||
_db.Medications.AnyAsync(m => m.Id == medicationId && m.UserId == userId, ct);
|
||||
|
||||
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
|
||||
_db.MedicationLogs.FirstOrDefaultAsync(l =>
|
||||
l.MedicationId == medicationId && l.UserId == userId
|
||||
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
|
||||
&& l.Status == MedicationLogStatus.Taken, ct);
|
||||
|
||||
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
|
||||
_db.MedicationLogs.AnyAsync(l =>
|
||||
l.MedicationId == medicationId && l.UserId == userId
|
||||
|
||||
@@ -19,8 +19,24 @@ public sealed class LocalReportFileStorage : IReportFileStorage
|
||||
return new StoredReportFile($"/uploads/reports/{fileName}", filePath);
|
||||
}
|
||||
|
||||
public string GetLocalFilePath(string fileUrl) =>
|
||||
Path.Combine(Directory.GetCurrentDirectory(), fileUrl.TrimStart('/'));
|
||||
public string GetLocalFilePath(string fileUrl)
|
||||
{
|
||||
var reportsRoot = Path.GetFullPath(Path.Combine(
|
||||
Directory.GetCurrentDirectory(),
|
||||
"uploads",
|
||||
"reports"));
|
||||
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||||
? absolute.AbsolutePath
|
||||
: fileUrl.Split('?', 2)[0];
|
||||
var fileName = Path.GetFileName(Uri.UnescapeDataString(path));
|
||||
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName))
|
||||
return Path.Combine(reportsRoot, "__invalid__");
|
||||
|
||||
var candidate = Path.GetFullPath(Path.Combine(reportsRoot, fileName));
|
||||
return candidate.StartsWith(reportsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||
? candidate
|
||||
: Path.Combine(reportsRoot, "__invalid__");
|
||||
}
|
||||
|
||||
public bool Exists(string filePath) =>
|
||||
File.Exists(filePath);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Health.Application.AI;
|
||||
using Health.Application.Reports;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Infrastructure.AI;
|
||||
@@ -82,7 +83,7 @@ public sealed class ReportAnalysisService(
|
||||
throw new InvalidOperationException("报告图片文件不存在或无法读取");
|
||||
|
||||
var prompt = """
|
||||
你是一名医学检验报告分析专家。请仔细识别这份医学报告。
|
||||
你是报告结构化提取助手。请识别这份医学报告。
|
||||
|
||||
第一步:判断这到底是不是一份医学检验报告。
|
||||
如果不是(比如是自拍、风景照、食物、文件等),直接返回:
|
||||
@@ -128,7 +129,7 @@ public sealed class ReportAnalysisService(
|
||||
}
|
||||
|
||||
var prompt = $$"""
|
||||
你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。
|
||||
你是报告结构化提取助手。下面是用户上传 PDF 中提取出的文字。
|
||||
|
||||
PDF 文本:
|
||||
{{text}}
|
||||
@@ -180,31 +181,32 @@ public sealed class ReportAnalysisService(
|
||||
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
|
||||
{
|
||||
var prompt = $"""
|
||||
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
|
||||
你是报告整理助手。请根据以下检验指标,整理一份报告摘要。
|
||||
|
||||
检测指标:{indicatorsJson}
|
||||
|
||||
要求:
|
||||
1. 总字数200-300字
|
||||
2. 先总结整体情况
|
||||
3. 指出异常指标及其可能原因,但不要给出确定诊断
|
||||
4. 给出生活方式和复查/就医沟通建议,不要给出处方、停药、换药或调药建议
|
||||
5. 如指标明显异常或存在急症风险,优先建议及时就医
|
||||
6. 末尾提醒"以上为AI预解读,不能替代医生诊断和治疗建议"
|
||||
2. 先总结整体情况(指标项数、异常项数)
|
||||
3. 列出异常指标及其数值和参考范围,客观说明"超出范围",不解释可能原因
|
||||
4. 不结合用户档案/病历做关联分析
|
||||
5. 建议就医评估异常指标
|
||||
6. 末尾提醒"以上为AI整理,不能替代医生诊断和治疗建议"
|
||||
7. 只使用固定医学来源目录中的 S-REPORT-01 和 S-REPORT-02 作为参考来源,不得编造其他来源
|
||||
|
||||
只返回解读文本,不要JSON格式。
|
||||
只返回整理文本,不要JSON格式。
|
||||
""";
|
||||
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" },
|
||||
new() { Role = "system", Content = "你是报告整理助手,不替代医生诊断。所有指标解读请用户咨询医生。\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 未返回有效解读内容");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Health.Application.Speech;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Health.Infrastructure.Speech;
|
||||
|
||||
public sealed class DashScopeSpeechRecognitionProxy : IRealtimeSpeechRecognitionProxy
|
||||
{
|
||||
private const int SampleRate = 16_000;
|
||||
private const int BytesPerSecond = SampleRate * 2;
|
||||
private readonly string _apiKey;
|
||||
private readonly string _model;
|
||||
private readonly string _workspaceId;
|
||||
private readonly Uri? _endpoint;
|
||||
private readonly int _maxDurationSeconds;
|
||||
private readonly ILogger<DashScopeSpeechRecognitionProxy> _logger;
|
||||
|
||||
public DashScopeSpeechRecognitionProxy(
|
||||
IConfiguration configuration,
|
||||
ILogger<DashScopeSpeechRecognitionProxy> logger)
|
||||
{
|
||||
_apiKey = FirstConfigured(
|
||||
configuration["DASHSCOPE_ASR_API_KEY"],
|
||||
configuration["VLM_API_KEY"],
|
||||
configuration["QWEN_API_KEY"]);
|
||||
_model = configuration["DASHSCOPE_ASR_MODEL"]?.Trim() is { Length: > 0 } model
|
||||
? model
|
||||
: "fun-asr-realtime";
|
||||
_workspaceId = configuration["DASHSCOPE_ASR_WORKSPACE_ID"]?.Trim() ?? "";
|
||||
var configuredMaxDuration = int.TryParse(
|
||||
configuration["DASHSCOPE_ASR_MAX_DURATION_SECONDS"],
|
||||
out var parsedMaxDuration)
|
||||
? parsedMaxDuration
|
||||
: 60;
|
||||
_maxDurationSeconds = Math.Clamp(configuredMaxDuration, 10, 180);
|
||||
|
||||
var configuredEndpoint = configuration["DASHSCOPE_ASR_WEBSOCKET_URL"]?.Trim();
|
||||
var endpoint = !string.IsNullOrWhiteSpace(configuredEndpoint)
|
||||
? configuredEndpoint
|
||||
: !string.IsNullOrWhiteSpace(_workspaceId)
|
||||
? $"wss://{_workspaceId}.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference"
|
||||
: "wss://dashscope.aliyuncs.com/api-ws/v1/inference";
|
||||
if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) && uri.Scheme == "wss")
|
||||
_endpoint = uri;
|
||||
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(_apiKey) && _endpoint is not null;
|
||||
|
||||
private static string FirstConfigured(params string?[] values) =>
|
||||
values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? "";
|
||||
|
||||
public async Task ProxyAsync(
|
||||
WebSocket clientSocket,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
throw new InvalidOperationException("实时语音识别服务尚未配置");
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
cancellationToken);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(_maxDurationSeconds + 15));
|
||||
var ct = timeoutCts.Token;
|
||||
|
||||
using var cloudSocket = new ClientWebSocket();
|
||||
cloudSocket.Options.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
|
||||
cloudSocket.Options.SetRequestHeader("User-Agent", "XiaomaiHealth/1.0");
|
||||
if (!string.IsNullOrWhiteSpace(_workspaceId))
|
||||
cloudSocket.Options.SetRequestHeader("X-DashScope-WorkSpace", _workspaceId);
|
||||
|
||||
await cloudSocket.ConnectAsync(_endpoint!, ct);
|
||||
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SendJsonAsync(cloudSocket, new
|
||||
{
|
||||
header = new
|
||||
{
|
||||
action = "run-task",
|
||||
task_id = taskId,
|
||||
streaming = "duplex",
|
||||
},
|
||||
payload = new
|
||||
{
|
||||
task_group = "audio",
|
||||
task = "asr",
|
||||
function = "recognition",
|
||||
model = _model,
|
||||
parameters = new
|
||||
{
|
||||
format = "pcm",
|
||||
sample_rate = SampleRate,
|
||||
language_hints = new[] { "zh" },
|
||||
semantic_punctuation_enabled = false,
|
||||
max_sentence_silence = 800,
|
||||
heartbeat = true,
|
||||
},
|
||||
input = new { },
|
||||
},
|
||||
}, ct);
|
||||
|
||||
await WaitForTaskStartedAsync(cloudSocket, taskId, ct);
|
||||
await SendClientEventAsync(clientSocket, new { type = "ready" }, ct);
|
||||
|
||||
var clientPump = PumpClientAudioAsync(
|
||||
clientSocket,
|
||||
cloudSocket,
|
||||
taskId,
|
||||
_maxDurationSeconds,
|
||||
ct);
|
||||
var cloudPump = PumpCloudResultsAsync(cloudSocket, clientSocket, ct);
|
||||
|
||||
var completed = await Task.WhenAny(clientPump, cloudPump);
|
||||
if (completed == cloudPump)
|
||||
{
|
||||
await cloudPump;
|
||||
timeoutCts.Cancel();
|
||||
}
|
||||
else
|
||||
{
|
||||
var action = await clientPump;
|
||||
if (action == ClientAction.Finish)
|
||||
{
|
||||
try
|
||||
{
|
||||
await cloudPump.WaitAsync(TimeSpan.FromSeconds(12), ct);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
await SendClientEventAsync(
|
||||
clientSocket,
|
||||
new { type = "error", message = "语音识别超时,请重试" },
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 客户端断开或硬超时,无需再给客户端发消息
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
timeoutCts.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
await CloseSocketQuietlyAsync(cloudSocket);
|
||||
await CloseSocketQuietlyAsync(clientSocket);
|
||||
}
|
||||
|
||||
private static async Task WaitForTaskStartedAsync(
|
||||
WebSocket cloudSocket,
|
||||
string taskId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var message = await ReceiveMessageAsync(cloudSocket, ct);
|
||||
if (message is null)
|
||||
throw new InvalidOperationException("语音识别服务提前断开");
|
||||
if (message.Value.Type != WebSocketMessageType.Text) continue;
|
||||
|
||||
using var json = JsonDocument.Parse(message.Value.Payload);
|
||||
var header = json.RootElement.GetProperty("header");
|
||||
var eventName = header.GetProperty("event").GetString();
|
||||
var responseTaskId = header.TryGetProperty("task_id", out var id)
|
||||
? id.GetString()
|
||||
: null;
|
||||
if (eventName == "task-started" && responseTaskId == taskId) return;
|
||||
if (eventName == "task-failed")
|
||||
{
|
||||
var error = header.TryGetProperty("error_message", out var errorMessage)
|
||||
? errorMessage.GetString()
|
||||
: "语音识别启动失败";
|
||||
throw new InvalidOperationException(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ClientAction> PumpClientAudioAsync(
|
||||
WebSocket clientSocket,
|
||||
WebSocket cloudSocket,
|
||||
string taskId,
|
||||
int maxDurationSeconds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var maxBytes = (long)BytesPerSecond * maxDurationSeconds;
|
||||
long audioBytes = 0;
|
||||
|
||||
while (!ct.IsCancellationRequested &&
|
||||
clientSocket.State == WebSocketState.Open)
|
||||
{
|
||||
var message = await ReceiveMessageAsync(clientSocket, ct);
|
||||
if (message is null) return ClientAction.Disconnect;
|
||||
|
||||
if (message.Value.Type == WebSocketMessageType.Binary)
|
||||
{
|
||||
audioBytes += message.Value.Payload.Length;
|
||||
if (audioBytes > maxBytes)
|
||||
{
|
||||
// 达到时长上限:主动结束并让阿里云返回已识别的结果,
|
||||
// 而不是直接断开。客户端松手后会拿到这段文字。
|
||||
await SendJsonAsync(cloudSocket, new
|
||||
{
|
||||
header = new
|
||||
{
|
||||
action = "finish-task",
|
||||
task_id = taskId,
|
||||
streaming = "duplex",
|
||||
},
|
||||
payload = new { input = new { } },
|
||||
}, ct);
|
||||
return ClientAction.Finish;
|
||||
}
|
||||
await cloudSocket.SendAsync(
|
||||
message.Value.Payload,
|
||||
WebSocketMessageType.Binary,
|
||||
true,
|
||||
ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.Value.Type != WebSocketMessageType.Text) continue;
|
||||
using var json = JsonDocument.Parse(message.Value.Payload);
|
||||
var action = json.RootElement.TryGetProperty("action", out var actionElement)
|
||||
? actionElement.GetString()
|
||||
: null;
|
||||
if (action == "cancel") return ClientAction.Cancel;
|
||||
if (action != "finish") continue;
|
||||
|
||||
await SendJsonAsync(cloudSocket, new
|
||||
{
|
||||
header = new
|
||||
{
|
||||
action = "finish-task",
|
||||
task_id = taskId,
|
||||
streaming = "duplex",
|
||||
},
|
||||
payload = new { input = new { } },
|
||||
}, ct);
|
||||
return ClientAction.Finish;
|
||||
}
|
||||
|
||||
return ClientAction.Disconnect;
|
||||
}
|
||||
|
||||
private async Task PumpCloudResultsAsync(
|
||||
WebSocket cloudSocket,
|
||||
WebSocket clientSocket,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var committed = new StringBuilder();
|
||||
var latest = "";
|
||||
|
||||
while (!ct.IsCancellationRequested &&
|
||||
cloudSocket.State == WebSocketState.Open)
|
||||
{
|
||||
var message = await ReceiveMessageAsync(cloudSocket, ct);
|
||||
if (message is null) return;
|
||||
if (message.Value.Type != WebSocketMessageType.Text) continue;
|
||||
|
||||
using var json = JsonDocument.Parse(message.Value.Payload);
|
||||
var root = json.RootElement;
|
||||
if (!root.TryGetProperty("header", out var header)) continue;
|
||||
var eventName = header.TryGetProperty("event", out var eventElement)
|
||||
? eventElement.GetString()
|
||||
: null;
|
||||
|
||||
if (eventName == "result-generated")
|
||||
{
|
||||
if (!TryReadSentence(root, out var sentence, out var sentenceEnd))
|
||||
continue;
|
||||
if (sentenceEnd && !string.IsNullOrWhiteSpace(sentence))
|
||||
committed.Append(sentence);
|
||||
latest = sentenceEnd
|
||||
? committed.ToString()
|
||||
: committed + sentence;
|
||||
await SendClientEventAsync(clientSocket, new
|
||||
{
|
||||
type = "partial",
|
||||
text = latest,
|
||||
sentenceEnd,
|
||||
}, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName == "task-finished")
|
||||
{
|
||||
await SendClientEventAsync(
|
||||
clientSocket,
|
||||
new { type = "done", text = latest.Trim() },
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName == "task-failed")
|
||||
{
|
||||
var error = header.TryGetProperty("error_message", out var errorElement)
|
||||
? errorElement.GetString()
|
||||
: "语音识别失败";
|
||||
_logger.LogWarning("DashScope ASR task failed: {Error}", error);
|
||||
await SendClientEventAsync(
|
||||
clientSocket,
|
||||
new { type = "error", message = "语音识别失败,请重试" },
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadSentence(
|
||||
JsonElement root,
|
||||
out string text,
|
||||
out bool sentenceEnd)
|
||||
{
|
||||
text = "";
|
||||
sentenceEnd = false;
|
||||
if (!root.TryGetProperty("payload", out var payload) ||
|
||||
!payload.TryGetProperty("output", out var output) ||
|
||||
!output.TryGetProperty("sentence", out var sentence))
|
||||
return false;
|
||||
|
||||
text = sentence.TryGetProperty("text", out var textElement)
|
||||
? textElement.GetString() ?? ""
|
||||
: "";
|
||||
sentenceEnd = sentence.TryGetProperty("sentence_end", out var endElement) &&
|
||||
endElement.ValueKind == JsonValueKind.True;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Task SendClientEventAsync(
|
||||
WebSocket socket,
|
||||
object value,
|
||||
CancellationToken ct) => SendJsonAsync(socket, value, ct);
|
||||
|
||||
private static async Task SendJsonAsync(
|
||||
WebSocket socket,
|
||||
object value,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (socket.State != WebSocketState.Open) return;
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(value);
|
||||
await socket.SendAsync(bytes, WebSocketMessageType.Text, true, ct);
|
||||
}
|
||||
|
||||
private static async Task<(WebSocketMessageType Type, byte[] Payload)?>
|
||||
ReceiveMessageAsync(WebSocket socket, CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[16 * 1024];
|
||||
using var stream = new MemoryStream();
|
||||
while (true)
|
||||
{
|
||||
var result = await socket.ReceiveAsync(buffer, ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close) return null;
|
||||
stream.Write(buffer, 0, result.Count);
|
||||
if (stream.Length > 2 * 1024 * 1024)
|
||||
throw new InvalidOperationException("WebSocket 消息过大");
|
||||
if (result.EndOfMessage)
|
||||
return (result.MessageType, stream.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CloseSocketQuietlyAsync(WebSocket socket)
|
||||
{
|
||||
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
|
||||
return;
|
||||
try
|
||||
{
|
||||
await socket.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure,
|
||||
"completed",
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端可能已经主动断开。
|
||||
}
|
||||
}
|
||||
|
||||
private enum ClientAction
|
||||
{
|
||||
Finish,
|
||||
Cancel,
|
||||
Disconnect,
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,23 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl
|
||||
|
||||
public Task DeleteAsync(Guid userId, AccountFileReferences references, CancellationToken ct)
|
||||
{
|
||||
var fileUrls = new HashSet<string>(references.FileUrls, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var metadataJson in references.ConversationMetadataJson)
|
||||
AddMetadataUrls(fileUrls, metadataJson);
|
||||
|
||||
foreach (var fileUrl in fileUrls)
|
||||
// 正式报告路径来自服务端生成的报告记录,只允许删除 reports 目录中的文件。
|
||||
foreach (var fileUrl in references.FileUrls)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var localPath = ResolveLocalPath(fileUrl);
|
||||
var localPath = ResolveReportPath(fileUrl);
|
||||
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||||
}
|
||||
|
||||
// 对话元数据可能包含客户端传入的 URL,只允许解析当前账号自己的目录。
|
||||
var attachmentUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var metadataJson in references.ConversationMetadataJson)
|
||||
AddMetadataUrls(attachmentUrls, metadataJson);
|
||||
|
||||
foreach (var fileUrl in attachmentUrls)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var localPath = ResolveOwnedAttachmentPath(userId, fileUrl);
|
||||
if (localPath != null && File.Exists(localPath)) File.Delete(localPath);
|
||||
}
|
||||
|
||||
@@ -52,31 +61,65 @@ public sealed class LocalAccountFileCleanup(string uploadsRoot) : IAccountFileCl
|
||||
if (!string.IsNullOrWhiteSpace(value)) fileUrls.Add(value);
|
||||
}
|
||||
|
||||
private string? ResolveLocalPath(string fileUrl)
|
||||
private string? ResolveReportPath(string fileUrl)
|
||||
{
|
||||
var urlPath = fileUrl;
|
||||
if (Uri.TryCreate(fileUrl, UriKind.Absolute, out var absoluteUri))
|
||||
urlPath = absoluteUri.AbsolutePath;
|
||||
|
||||
var uploadsIndex = urlPath.IndexOf("/uploads/", StringComparison.OrdinalIgnoreCase);
|
||||
if (uploadsIndex < 0) return null;
|
||||
|
||||
string relativePath;
|
||||
try
|
||||
{
|
||||
relativePath = Uri.UnescapeDataString(urlPath[(uploadsIndex + "/uploads/".Length)..]);
|
||||
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||||
? absolute.AbsolutePath
|
||||
: fileUrl.Split('?', 2)[0];
|
||||
var prefixIndex = path.IndexOf("/uploads/reports/", StringComparison.OrdinalIgnoreCase);
|
||||
if (prefixIndex < 0) return null;
|
||||
var fileName = Uri.UnescapeDataString(path[(prefixIndex + "/uploads/reports/".Length)..]);
|
||||
return ResolveInside(Path.Combine(_uploadsRoot, "reports"), fileName);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var queryIndex = relativePath.IndexOfAny(['?', '#']);
|
||||
if (queryIndex >= 0) relativePath = relativePath[..queryIndex];
|
||||
relativePath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
private string? ResolveOwnedAttachmentPath(Guid userId, string fileUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Uri.TryCreate(fileUrl, UriKind.Absolute, out var absolute)
|
||||
? absolute.AbsolutePath
|
||||
: fileUrl.Split('?', 2)[0];
|
||||
path = Uri.UnescapeDataString(path);
|
||||
var protectedPrefix = "/api/files/content/";
|
||||
var protectedIndex = path.IndexOf(protectedPrefix, StringComparison.OrdinalIgnoreCase);
|
||||
var legacyPrefix = $"/uploads/users/{userId:N}/";
|
||||
var legacyIndex = path.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
|
||||
var fileName = protectedIndex >= 0
|
||||
? path[(protectedIndex + protectedPrefix.Length)..]
|
||||
: legacyIndex >= 0
|
||||
? path[(legacyIndex + legacyPrefix.Length)..]
|
||||
: null;
|
||||
return fileName == null
|
||||
? null
|
||||
: ResolveInside(Path.Combine(_uploadsRoot, "users", userId.ToString("N")), fileName);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(Path.Combine(_uploadsRoot, relativePath));
|
||||
var rootPrefix = _uploadsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
private static string? ResolveInside(string root, string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName) || fileName != Path.GetFileName(fileName)) return null;
|
||||
var fullRoot = Path.GetFullPath(root);
|
||||
var fullPath = Path.GetFullPath(Path.Combine(fullRoot, fileName));
|
||||
var rootPrefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
return fullPath.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ? fullPath : null;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,20 @@ public static class DoctorEndpoints
|
||||
return (startUtc, startUtc.AddDays(1));
|
||||
}
|
||||
|
||||
private static DateTime ParseBeijingDateTimeAsUtc(string value)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(value, out var offset) &&
|
||||
(value.EndsWith("Z", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.LastIndexOf('+') > 9 ||
|
||||
value.LastIndexOf('-') > 9))
|
||||
return offset.UtcDateTime;
|
||||
|
||||
var beijing = DateTime.Parse(value);
|
||||
return DateTime.SpecifyKind(
|
||||
DateTime.SpecifyKind(beijing, DateTimeKind.Unspecified).AddHours(-8),
|
||||
DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
public static void MapDoctorEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/doctor").RequireAuthorization();
|
||||
@@ -304,7 +318,7 @@ public static class DoctorEndpoints
|
||||
query = query.Where(r => r.Status == s);
|
||||
|
||||
var reports = await query.OrderByDescending(r => r.CreatedAt)
|
||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||
.ToListAsync();
|
||||
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
||||
});
|
||||
@@ -317,7 +331,7 @@ public static class DoctorEndpoints
|
||||
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||
|
||||
var report = await db.Reports.Where(r => r.Id == id && r.User.DoctorId == doctorId)
|
||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||
.Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, FileUrl = "/api/reports/" + r.Id + "/file", FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt })
|
||||
.FirstOrDefaultAsync();
|
||||
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
||||
return Results.Ok(new { code = 0, data = report, message = (string?)null });
|
||||
@@ -391,7 +405,7 @@ public static class DoctorEndpoints
|
||||
Title = json.RootElement.GetProperty("title").GetString() ?? "",
|
||||
DoctorName = profile.Name,
|
||||
Department = profile.Department,
|
||||
ScheduledAt = DateTime.Parse(json.RootElement.GetProperty("scheduledAt").GetString()!),
|
||||
ScheduledAt = ParseBeijingDateTimeAsUtc(json.RootElement.GetProperty("scheduledAt").GetString()!),
|
||||
Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null,
|
||||
Status = FollowUpStatus.Upcoming,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
@@ -415,7 +429,7 @@ public static class DoctorEndpoints
|
||||
var body = await reader.ReadToEndAsync(ct);
|
||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||
if (json.RootElement.TryGetProperty("title", out var t)) followUp.Title = t.GetString() ?? followUp.Title;
|
||||
if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = DateTime.Parse(sa.GetString()!);
|
||||
if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = ParseBeijingDateTimeAsUtc(sa.GetString()!);
|
||||
if (json.RootElement.TryGetProperty("notes", out var no)) followUp.Notes = no.GetString();
|
||||
if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse<FollowUpStatus>(st.GetString(), out var fs)) followUp.Status = fs;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Health.Infrastructure.Files;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class FileEndpoints
|
||||
@@ -47,24 +49,48 @@ public static class FileEndpoints
|
||||
|
||||
var storedName = $"{fileId}{ext}";
|
||||
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
|
||||
await using var stream = new FileStream(filePath, FileMode.Create);
|
||||
await using var stream = new FileStream(filePath, FileMode.CreateNew);
|
||||
await file.CopyToAsync(stream, ct);
|
||||
results.Add(new
|
||||
{
|
||||
id = fileId,
|
||||
name = file.FileName,
|
||||
size = file.Length,
|
||||
url = $"/uploads/users/{userDirectoryName}/{storedName}",
|
||||
url = $"/api/files/content/{storedName}",
|
||||
contentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/content/{fileName}", (string fileName, HttpContext http) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var filePath = UserUploadPathResolver.Resolve(userId, fileName);
|
||||
if (filePath == null || !File.Exists(filePath))
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.File(
|
||||
filePath,
|
||||
ContentTypeFor(filePath),
|
||||
enableRangeProcessing: true);
|
||||
});
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id)
|
||||
? id
|
||||
: Guid.Empty;
|
||||
|
||||
private static string ContentTypeFor(string path) =>
|
||||
Path.GetExtension(path).ToLowerInvariant() switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".webp" => "image/webp",
|
||||
".gif" => "image/gif",
|
||||
".pdf" => "application/pdf",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,15 +42,6 @@ public static class MedicationEndpoints
|
||||
: Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" });
|
||||
});
|
||||
|
||||
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var taken = await medications.ToggleTodayTakenAsync(userId, id, ct);
|
||||
if (taken == null) return Results.Ok(new { code = 404, message = "药品不存在" });
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { taken }, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/reminders", async (HttpContext http, IMedicationService medications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
|
||||
@@ -24,6 +24,47 @@ public static class ReportEndpoints
|
||||
: Results.Ok(new { code = 0, data = report, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/{id:guid}/file", async (
|
||||
Guid id,
|
||||
HttpContext http,
|
||||
AppDbContext db,
|
||||
IReportFileStorage fileStorage,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var currentUserId = GetUserId(http);
|
||||
var report = await db.Reports
|
||||
.Include(r => r.User)
|
||||
.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
if (report == null)
|
||||
return Results.NotFound();
|
||||
|
||||
var allowed = report.UserId == currentUserId;
|
||||
if (!allowed && GetRole(http) == "Doctor")
|
||||
{
|
||||
var doctorId = await db.DoctorProfiles
|
||||
.Where(profile => profile.UserId == currentUserId)
|
||||
.Select(profile => profile.DoctorId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
allowed = doctorId != null && report.User.DoctorId == doctorId;
|
||||
}
|
||||
if (!allowed)
|
||||
return Results.NotFound();
|
||||
|
||||
var filePath = fileStorage.GetLocalFilePath(report.FileUrl);
|
||||
if (!fileStorage.Exists(filePath))
|
||||
return Results.NotFound();
|
||||
|
||||
var contentType = Path.GetExtension(filePath).ToLowerInvariant() switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".webp" => "image/webp",
|
||||
".pdf" => "application/pdf",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
return Results.File(filePath, contentType, enableRangeProcessing: true);
|
||||
});
|
||||
|
||||
group.MapPost("/", async (HttpContext http, IReportService reports, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
@@ -66,4 +107,9 @@ public static class ReportEndpoints
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
|
||||
private static string GetRole(HttpContext http) =>
|
||||
http.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ??
|
||||
http.User.FindFirst("Role")?.Value ??
|
||||
"User";
|
||||
}
|
||||
|
||||
69
backend/src/Health.WebApi/Endpoints/speech_endpoints.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using Health.Application.Speech;
|
||||
|
||||
namespace Health.WebApi.Endpoints;
|
||||
|
||||
public static class SpeechEndpoints
|
||||
{
|
||||
public static void MapSpeechEndpoints(this WebApplication app)
|
||||
{
|
||||
app.Map("/api/speech/realtime", async (
|
||||
HttpContext http,
|
||||
IRealtimeSpeechRecognitionProxy proxy,
|
||||
ILoggerFactory loggerFactory) =>
|
||||
{
|
||||
if (!http.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
http.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await http.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
code = 40001,
|
||||
data = (object?)null,
|
||||
message = "该接口仅支持 WebSocket 连接",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!proxy.IsConfigured)
|
||||
{
|
||||
http.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
await http.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
code = 50301,
|
||||
data = (object?)null,
|
||||
message = "实时语音识别服务尚未配置",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
using var socket = await http.WebSockets.AcceptWebSocketAsync();
|
||||
try
|
||||
{
|
||||
await proxy.ProxyAsync(socket, http.RequestAborted);
|
||||
}
|
||||
catch (OperationCanceledException) when (http.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
// 客户端离开页面或断开网络。
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
loggerFactory.CreateLogger("SpeechEndpoints")
|
||||
.LogError(ex, "Realtime speech proxy failed");
|
||||
if (socket.State == WebSocketState.Open)
|
||||
{
|
||||
var payload = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
type = "error",
|
||||
message = "语音服务暂时不可用,请稍后重试",
|
||||
});
|
||||
await socket.SendAsync(
|
||||
payload,
|
||||
WebSocketMessageType.Text,
|
||||
true,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}).RequireAuthorization(policy => policy.RequireRole("User"));
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,12 @@ public static class UserEndpoints
|
||||
var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
|
||||
var updated = await users.UpdateProfileAsync(
|
||||
GetUserId(http),
|
||||
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate),
|
||||
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate, req.AvatarUrl),
|
||||
ct);
|
||||
if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" });
|
||||
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
var profile = await users.GetProfileAsync(GetUserId(http), ct);
|
||||
return Results.Ok(new { code = 0, data = profile, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
|
||||
@@ -66,7 +67,7 @@ public static class UserEndpoints
|
||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||
}
|
||||
|
||||
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate);
|
||||
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate, string? AvatarUrl);
|
||||
public sealed record UpdateArchiveRequest(
|
||||
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
||||
List<string>? Allergies, List<string>? DietRestrictions,
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
// 客户端或启动探针取消请求时,不再尝试写入错误响应。
|
||||
}
|
||||
catch (Health.Domain.ValidationException vex)
|
||||
{
|
||||
// 业务输入校验失败:返回 400,message 为我们自己产生的提示,可安全展示
|
||||
@@ -37,6 +41,7 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
||||
{
|
||||
// 生产环境不暴露内部异常详情
|
||||
_logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path);
|
||||
if (context.Response.HasStarted) return;
|
||||
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ using Health.Application.Medications;
|
||||
using Health.Application.Maintenance;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Application.Reports;
|
||||
using Health.Application.Speech;
|
||||
using Health.Application.Users;
|
||||
using Health.Infrastructure.AI;
|
||||
using Health.Infrastructure.Admin;
|
||||
@@ -25,6 +26,7 @@ using Health.Infrastructure.Medications;
|
||||
using Health.Infrastructure.Maintenance;
|
||||
using Health.Infrastructure.Notifications;
|
||||
using Health.Infrastructure.Reports;
|
||||
using Health.Infrastructure.Speech;
|
||||
using Health.Infrastructure.Services;
|
||||
using Health.Infrastructure.Users;
|
||||
using Health.WebApi.BackgroundServices;
|
||||
@@ -34,7 +36,6 @@ using Health.WebApi.Middleware;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
// 加载 .env 文件(开发环境)
|
||||
@@ -111,6 +112,7 @@ builder.Services.AddSingleton<SmsService>();
|
||||
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
|
||||
builder.Services.AddSingleton<PromptManager>();
|
||||
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
||||
builder.Services.AddScoped<IAiEntryDraftStore, EfAiEntryDraftStore>();
|
||||
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
||||
builder.Services.AddScoped<IAdminService, AdminService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
@@ -151,6 +153,7 @@ builder.Services.AddScoped<IReportAnalysisService, ReportAnalysisService>();
|
||||
builder.Services.AddScoped<IReportAnalysisQueue, EfReportAnalysisQueue>();
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<IUserRepository, EfUserRepository>();
|
||||
builder.Services.AddSingleton<IRealtimeSpeechRecognitionProxy, DashScopeSpeechRecognitionProxy>();
|
||||
builder.Services.AddScoped<IAccountFileCleanup>(_ => new LocalAccountFileCleanup(
|
||||
Path.Combine(Directory.GetCurrentDirectory(), "uploads")));
|
||||
|
||||
@@ -163,8 +166,10 @@ builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||
});
|
||||
builder.Services.AddHttpClient<VisionClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? "");
|
||||
var baseUrl = builder.Configuration["VLM_BASE_URL"] ?? builder.Configuration["QWEN_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
var apiKey = builder.Configuration["VLM_API_KEY"] ?? builder.Configuration["QWEN_API_KEY"] ?? "";
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
|
||||
@@ -206,18 +211,17 @@ app.UseMiddleware<ExceptionMiddleware>();
|
||||
app.UseCors();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseWebSockets(new WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(20),
|
||||
});
|
||||
|
||||
// 默认 wwwroot 静态文件(法律文档 H5 页面等)
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||
Directory.CreateDirectory(uploadsPath);
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(uploadsPath),
|
||||
RequestPath = "/uploads"
|
||||
});
|
||||
// 用户上传文件不能作为静态目录公开;统一通过带鉴权和归属校验的 API 读取。
|
||||
Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), "uploads"));
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
@@ -246,6 +250,7 @@ app.MapNotificationEndpoints();
|
||||
app.MapDoctorEndpoints();
|
||||
app.MapAdminEndpoints();
|
||||
app.MapFollowUpEndpoints();
|
||||
app.MapSpeechEndpoints();
|
||||
|
||||
// SignalR Hub
|
||||
app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"JWT_AUDIENCE": "health-manager-app",
|
||||
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
||||
"DEEPSEEK_API_KEY": "sk-your-key-here",
|
||||
"DEEPSEEK_MODEL": "deepseek-chat",
|
||||
"DEEPSEEK_MODEL": "deepseek-v4-flash",
|
||||
"ENABLE_MEDICAL_RAG": false,
|
||||
"QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"QWEN_API_KEY": "sk-your-key-here",
|
||||
"QWEN_VISION_MODEL": "qwen-vl-max",
|
||||
|
||||
@@ -13,8 +13,10 @@ public sealed class AccountDeletionTests
|
||||
var userId = Guid.NewGuid();
|
||||
var userDirectory = Path.Combine(root, "users", userId.ToString("N"));
|
||||
var reportPath = Path.Combine(root, "reports", "owned-report.pdf");
|
||||
var chatImagePath = Path.Combine(root, "owned-chat.jpg");
|
||||
var otherUserPath = Path.Combine(root, "users", Guid.NewGuid().ToString("N"), "keep.jpg");
|
||||
var chatFileName = $"{Guid.NewGuid()}.jpg";
|
||||
var chatImagePath = Path.Combine(userDirectory, chatFileName);
|
||||
var otherUserId = Guid.NewGuid();
|
||||
var otherUserPath = Path.Combine(root, "users", otherUserId.ToString("N"), "keep.jpg");
|
||||
var outsidePath = Path.Combine(Path.GetDirectoryName(root)!, "outside-account-file.txt");
|
||||
|
||||
try
|
||||
@@ -31,7 +33,7 @@ public sealed class AccountDeletionTests
|
||||
var cleanup = new LocalAccountFileCleanup(root);
|
||||
var references = new AccountFileReferences(
|
||||
["/uploads/reports/owned-report.pdf", "/uploads/../outside-account-file.txt"],
|
||||
["{\"imageUrl\":\"/uploads/owned-chat.jpg\"}"]);
|
||||
[$"{{\"imageUrl\":\"/uploads/users/{userId:N}/{chatFileName}\"}}", $"{{\"imageUrl\":\"/uploads/users/{otherUserId:N}/keep.jpg\"}}"]);
|
||||
|
||||
await cleanup.DeleteAsync(userId, references, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ using Health.Application.Exercises;
|
||||
using Health.Domain;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Infrastructure.AI.AgentHandlers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
@@ -95,6 +97,66 @@ public sealed class ApplicationServiceTests
|
||||
Assert.Equal(1, repository.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEntryBatch_ExecutesAllMetricsWithOneSave()
|
||||
{
|
||||
var repository = new CapturingHealthRecordRepository();
|
||||
var service = new HealthRecordService(repository);
|
||||
using var arguments = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"metrics": [
|
||||
{ "type": "blood_pressure", "systolic": 113, "diastolic": 86 },
|
||||
{ "type": "heart_rate", "heart_rate": 80 },
|
||||
{ "type": "glucose", "glucose": 5.6 }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await HealthDataAgentHandler.Execute(
|
||||
"record_health_data_batch",
|
||||
arguments.RootElement,
|
||||
Guid.NewGuid(),
|
||||
service,
|
||||
CancellationToken.None);
|
||||
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||
|
||||
Assert.True(resultJson.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Equal(3, resultJson.RootElement.GetProperty("record_ids").GetArrayLength());
|
||||
Assert.Equal(3, resultJson.RootElement.GetProperty("items").GetArrayLength());
|
||||
Assert.Equal(3, repository.Added.Count);
|
||||
Assert.Equal(1, repository.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEntryBatch_IncompleteMetricBlocksWholeBatch()
|
||||
{
|
||||
var repository = new CapturingHealthRecordRepository();
|
||||
var service = new HealthRecordService(repository);
|
||||
using var arguments = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"metrics": [
|
||||
{ "type": "blood_pressure", "systolic": 113 },
|
||||
{ "type": "heart_rate", "heart_rate": 80 }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await HealthDataAgentHandler.Execute(
|
||||
"record_health_data_batch",
|
||||
arguments.RootElement,
|
||||
Guid.NewGuid(),
|
||||
service,
|
||||
CancellationToken.None);
|
||||
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||
|
||||
Assert.False(resultJson.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("舒张压", resultJson.RootElement.GetProperty("message").GetString());
|
||||
Assert.Empty(repository.Added);
|
||||
Assert.Equal(0, repository.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
|
||||
{
|
||||
@@ -211,6 +273,24 @@ public sealed class ApplicationServiceTests
|
||||
Assert.Equal(["medication", "exercise", "followup"], events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Calendar_FollowUpUsesBeijingDateForUtcTimestamp()
|
||||
{
|
||||
var followUp = new FollowUp
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ScheduledAt = new DateTime(2026, 6, 18, 17, 0, 0, DateTimeKind.Utc),
|
||||
Title = "北京时间复查"
|
||||
};
|
||||
var service = new CalendarService(new FakeCalendarRepository(
|
||||
new CalendarDataSnapshot([], [], [followUp])));
|
||||
|
||||
var result = await service.GetMonthAsync(Guid.NewGuid(), 2026, 6, CancellationToken.None);
|
||||
|
||||
var target = Assert.Single(result);
|
||||
Assert.Equal("2026-06-19", target.GetType().GetProperty("date")!.GetValue(target));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExercisePlan_TenDays_CreatesTenUniqueConsecutiveDates()
|
||||
{
|
||||
@@ -252,6 +332,129 @@ public sealed class ApplicationServiceTests
|
||||
Assert.Equal(2, current.Items.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiExerciseQuery_UpcomingScopeReturnsFuturePlanButTodayScopeDoesNot()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var repository = new FakeExerciseRepository();
|
||||
var plan = new ExercisePlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
StartDate = today.AddDays(2),
|
||||
EndDate = today.AddDays(4),
|
||||
ReminderTime = new TimeOnly(19, 0),
|
||||
};
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ScheduledDate = today.AddDays(2),
|
||||
ExerciseType = "散步",
|
||||
DurationMinutes = 30,
|
||||
});
|
||||
repository.SetPlan(plan);
|
||||
var service = new ExerciseService(repository);
|
||||
using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}""");
|
||||
using var todayArgs = JsonDocument.Parse("""{"action":"query","scope":"today"}""");
|
||||
|
||||
var upcoming = await ExerciseAgentHandler.Execute(
|
||||
"manage_exercise", upcomingArgs.RootElement, userId, service, CancellationToken.None);
|
||||
var todayResult = await ExerciseAgentHandler.Execute(
|
||||
"manage_exercise", todayArgs.RootElement, userId, service, CancellationToken.None);
|
||||
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions));
|
||||
using var todayJson = JsonDocument.Parse(JsonSerializer.Serialize(todayResult, jsonOptions));
|
||||
|
||||
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
|
||||
Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
|
||||
Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiExerciseQuery_AuthoritativeAnswerPreservesStoredExerciseType()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var repository = new FakeExerciseRepository();
|
||||
var plan = new ExercisePlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
StartDate = today,
|
||||
EndDate = today.AddDays(6),
|
||||
ReminderTime = new TimeOnly(15, 0),
|
||||
};
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ScheduledDate = today,
|
||||
ExerciseType = "打篮球",
|
||||
DurationMinutes = 30,
|
||||
});
|
||||
repository.SetPlan(plan);
|
||||
var service = new ExerciseService(repository);
|
||||
using var arguments = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}""");
|
||||
|
||||
var result = await ExerciseAgentHandler.Execute(
|
||||
"manage_exercise", arguments.RootElement, userId, service, CancellationToken.None);
|
||||
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||
var answer = json.RootElement.GetProperty("authoritative_answer").GetString();
|
||||
|
||||
Assert.Contains("打篮球", answer);
|
||||
Assert.DoesNotContain("打毽子", answer);
|
||||
Assert.Contains("15:00", answer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MedicationEntry_AcceptsPillCountAsDosage()
|
||||
{
|
||||
using var arguments = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"action": "create",
|
||||
"name": "维生素D",
|
||||
"dosage": "1粒",
|
||||
"frequency": "Daily",
|
||||
"time_of_day": ["12:00"],
|
||||
"start_date": "2026-07-27",
|
||||
"duration_days": 3
|
||||
}
|
||||
""");
|
||||
|
||||
var validationError = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
|
||||
|
||||
Assert.Null(validationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiExerciseQuery_RequiresExplicitScopeInsteadOfAssumingToday()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var service = new ExerciseService(new FakeExerciseRepository());
|
||||
using var args = JsonDocument.Parse("""{"action":"query"}""");
|
||||
|
||||
var result = await ExerciseAgentHandler.Execute(
|
||||
"manage_exercise", args.RootElement, userId, service, CancellationToken.None);
|
||||
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||
|
||||
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiFollowUpQuery_RequiresExplicitScopeInsteadOfAssumingNext()
|
||||
{
|
||||
using var args = JsonDocument.Parse("""{}""");
|
||||
|
||||
var result = await PatientReadAgentHandler.QueryFollowUpsAsync(
|
||||
null!, Guid.NewGuid(), args.RootElement, CancellationToken.None);
|
||||
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||
|
||||
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExercisePlan_CheckInRejectsNonTodayItem()
|
||||
{
|
||||
@@ -360,9 +563,11 @@ public sealed class ApplicationServiceTests
|
||||
public void SetPlan(ExercisePlan plan) => Plan = plan;
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListActiveOnAsync(Guid userId, DateOnly date, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||
Plan != null && Plan.UserId == userId && Plan.StartDate <= date && Plan.EndDate >= date ? [Plan] : []);
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>([]);
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListAsync(Guid userId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||
Plan != null && Plan.UserId == userId ? [Plan] : []);
|
||||
public Task<IReadOnlyList<ExercisePlan>> ListAllAsync(Guid userId, CancellationToken ct) => Task.FromResult<IReadOnlyList<ExercisePlan>>(
|
||||
Plan != null && Plan.UserId == userId ? [Plan] : []);
|
||||
public Task<ExercisePlan?> GetOwnedPlanAsync(Guid userId, Guid planId, CancellationToken ct) => Task.FromResult(Plan);
|
||||
public Task<ExercisePlan?> GetLatestAsync(Guid userId, CancellationToken ct) => Task.FromResult(Plan);
|
||||
public Task<ExercisePlanItem?> GetOwnedItemAsync(Guid userId, Guid itemId, CancellationToken ct) => Task.FromResult(Plan?.Items.FirstOrDefault(x => x.Id == itemId));
|
||||
public Task AddAsync(ExercisePlan plan, CancellationToken ct) { Plan = plan; return Task.CompletedTask; }
|
||||
public void Delete(ExercisePlan plan) { Plan = null; }
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Infrastructure.Auth;
|
||||
using Health.Infrastructure.Data;
|
||||
using Health.Infrastructure.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -118,6 +120,47 @@ public class AuthTests
|
||||
Assert.NotNull(active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_Should_Return_Stable_User_Identity()
|
||||
{
|
||||
using var db = CreateDbContext();
|
||||
var config = CreateConfig();
|
||||
var jwt = new JwtProvider(config);
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Phone = "13800138000",
|
||||
Role = "User",
|
||||
Name = "测试用户",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
var oldRefresh = jwt.GenerateRefreshToken();
|
||||
db.Users.Add(user);
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = user.Id,
|
||||
Token = oldRefresh,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = new AuthService(
|
||||
db,
|
||||
jwt,
|
||||
new SmsService(),
|
||||
new AppleTokenValidator(config));
|
||||
|
||||
var result = await service.RefreshAsync(oldRefresh, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.Code);
|
||||
var data = JsonSerializer.SerializeToElement(result.Data);
|
||||
var refreshedUser = data.GetProperty("user");
|
||||
Assert.Equal(user.Id, refreshedUser.GetProperty("Id").GetGuid());
|
||||
Assert.Equal(user.Phone, refreshedUser.GetProperty("Phone").GetString());
|
||||
Assert.Equal(user.Role, refreshedUser.GetProperty("Role").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerificationCode_Expired_Should_Fail_Login()
|
||||
{
|
||||
|
||||
40
backend/tests/Health.Tests/file_path_security_tests.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Health.Infrastructure.Files;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
public sealed class FilePathSecurityTests
|
||||
{
|
||||
private readonly Guid _userId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
private readonly string _fileName = "22222222-2222-2222-2222-222222222222.jpg";
|
||||
|
||||
[Fact]
|
||||
public void ProtectedUrl_ResolvesInsideCurrentUserDirectory()
|
||||
{
|
||||
var path = UserUploadPathResolver.Resolve(
|
||||
_userId,
|
||||
$"/api/files/content/{_fileName}");
|
||||
|
||||
Assert.NotNull(path);
|
||||
Assert.Contains(Path.Combine("users", _userId.ToString("N")), path);
|
||||
Assert.EndsWith(_fileName, path, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyUrl_OnlyResolvesForItsOwner()
|
||||
{
|
||||
var legacy = $"/uploads/users/{_userId:N}/{_fileName}";
|
||||
|
||||
Assert.NotNull(UserUploadPathResolver.Resolve(_userId, legacy));
|
||||
Assert.Null(UserUploadPathResolver.Resolve(Guid.NewGuid(), legacy));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/files/content/../../secret.jpg")]
|
||||
[InlineData("/api/files/content/not-a-guid.jpg")]
|
||||
[InlineData("/api/files/content/22222222-2222-2222-2222-222222222222.exe")]
|
||||
[InlineData("/uploads/reports/22222222-2222-2222-2222-222222222222.jpg")]
|
||||
public void UnsafeOrUnownedPath_IsRejected(string value)
|
||||
{
|
||||
Assert.Null(UserUploadPathResolver.Resolve(_userId, value));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using Health.Application.Medications;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Infrastructure.AI.AgentHandlers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Tests;
|
||||
|
||||
@@ -32,21 +35,227 @@ public sealed class MedicationUpdateTests
|
||||
Assert.True(repository.Saved);
|
||||
}
|
||||
|
||||
private sealed class FakeMedicationRepository(Medication medication) : IMedicationRepository
|
||||
[Fact]
|
||||
public async Task AiOverview_DoesNotScheduleFutureMedicationForToday()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "未来用药",
|
||||
IsActive = true,
|
||||
StartDate = today.AddDays(2),
|
||||
EndDate = today.AddDays(10),
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
};
|
||||
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||
|
||||
var overview = await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None);
|
||||
|
||||
var plan = Assert.Single(overview);
|
||||
Assert.Equal("upcoming", plan.Phase);
|
||||
Assert.False(plan.ScheduledOnDate);
|
||||
Assert.Empty(plan.Doses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiOverview_ReportsEachDoseInsteadOfAnyTakenAggregate()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "两顿药",
|
||||
IsActive = true,
|
||||
StartDate = today.AddDays(-1),
|
||||
EndDate = today.AddDays(1),
|
||||
Frequency = MedicationFrequency.TwiceDaily,
|
||||
TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)],
|
||||
};
|
||||
var log = new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = medication.UserId,
|
||||
MedicationId = medication.Id,
|
||||
ScheduledTime = new TimeOnly(8, 0),
|
||||
Status = MedicationLogStatus.Taken,
|
||||
ConfirmedAt = DateTime.UtcNow,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var service = new MedicationService(new FakeMedicationRepository(medication, [log]));
|
||||
|
||||
var plan = Assert.Single(await service.GetAiOverviewAsync(medication.UserId, today, CancellationToken.None));
|
||||
|
||||
Assert.Equal(2, plan.Doses.Count);
|
||||
Assert.Equal("taken", plan.Doses.Single(dose => dose.ScheduledTime == "08:00").Status);
|
||||
Assert.NotEqual("taken", plan.Doses.Single(dose => dose.ScheduledTime == "20:00").Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmDose_RejectsAClockTimeThatIsNotInTodaysPlan()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "阿司匹林",
|
||||
IsActive = true,
|
||||
StartDate = today,
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
};
|
||||
var repository = new FakeMedicationRepository(medication);
|
||||
var service = new MedicationService(repository);
|
||||
|
||||
var result = await service.ConfirmDoseAsync(
|
||||
medication.UserId,
|
||||
medication.Id,
|
||||
new TimeOnly(8, 15),
|
||||
MedicationLogStatus.Taken,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Empty(repository.AddedLogs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AiMedicationCreate_RejectsMissingFieldsInsteadOfUsingDefaults()
|
||||
{
|
||||
using var arguments = JsonDocument.Parse("""{"action":"create","name":"阿司匹林"}""");
|
||||
|
||||
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
|
||||
|
||||
Assert.Equal("创建用药计划前需要确认每次剂量", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AiMedicationCreate_AcceptsCompleteFinitePlan()
|
||||
{
|
||||
using var arguments = JsonDocument.Parse("""
|
||||
{
|
||||
"action":"create",
|
||||
"name":"阿司匹林",
|
||||
"dosage":"100mg",
|
||||
"frequency":"Daily",
|
||||
"time_of_day":["08:00"],
|
||||
"start_date":"2026-07-20",
|
||||
"duration_days":30
|
||||
}
|
||||
""");
|
||||
|
||||
var error = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
|
||||
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiMedicationQuery_UpcomingScopeReturnsTomorrowPlanButCurrentScopeDoesNot()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "明天开始的药",
|
||||
Dosage = "1片",
|
||||
IsActive = true,
|
||||
StartDate = today.AddDays(1),
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
};
|
||||
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||
using var upcomingArgs = JsonDocument.Parse("""{"action":"query","scope":"upcoming_plans"}""");
|
||||
using var currentArgs = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}""");
|
||||
|
||||
var upcoming = await MedicationAgentHandler.Execute(
|
||||
"manage_medication", upcomingArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||
var current = await MedicationAgentHandler.Execute(
|
||||
"manage_medication", currentArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
using var upcomingJson = JsonDocument.Parse(JsonSerializer.Serialize(upcoming, jsonOptions));
|
||||
using var currentJson = JsonDocument.Parse(JsonSerializer.Serialize(current, jsonOptions));
|
||||
|
||||
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
|
||||
Assert.True(upcomingJson.RootElement.GetProperty("has_upcoming_plans").GetBoolean());
|
||||
Assert.Equal(1, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
|
||||
Assert.Equal("明天开始的药", upcomingJson.RootElement.GetProperty("plans")[0].GetProperty("name").GetString());
|
||||
Assert.Equal(0, currentJson.RootElement.GetProperty("count").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiMedicationQuery_RequiresExplicitScopeInsteadOfAssumingToday()
|
||||
{
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "测试药物",
|
||||
IsActive = true,
|
||||
StartDate = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
};
|
||||
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||
using var args = JsonDocument.Parse("""{"action":"query"}""");
|
||||
|
||||
var result = await MedicationAgentHandler.Execute(
|
||||
"manage_medication", args.RootElement, medication.UserId, service, CancellationToken.None);
|
||||
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
|
||||
|
||||
Assert.False(json.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("scope", json.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AiMedicationQuery_SeparatesInactivePlansFromNaturallyEndedPlans()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var medication = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Name = "已停用药物",
|
||||
IsActive = false,
|
||||
StartDate = today.AddDays(-10),
|
||||
EndDate = today.AddDays(10),
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(8, 0)],
|
||||
};
|
||||
var service = new MedicationService(new FakeMedicationRepository(medication));
|
||||
using var inactiveArgs = JsonDocument.Parse("""{"action":"query","scope":"inactive_plans"}""");
|
||||
using var endedArgs = JsonDocument.Parse("""{"action":"query","scope":"ended_plans"}""");
|
||||
|
||||
var inactive = await MedicationAgentHandler.Execute(
|
||||
"manage_medication", inactiveArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||
var ended = await MedicationAgentHandler.Execute(
|
||||
"manage_medication", endedArgs.RootElement, medication.UserId, service, CancellationToken.None);
|
||||
using var inactiveJson = JsonDocument.Parse(JsonSerializer.Serialize(inactive));
|
||||
using var endedJson = JsonDocument.Parse(JsonSerializer.Serialize(ended));
|
||||
|
||||
Assert.Equal(1, inactiveJson.RootElement.GetProperty("count").GetInt32());
|
||||
Assert.Equal(0, endedJson.RootElement.GetProperty("count").GetInt32());
|
||||
}
|
||||
|
||||
private sealed class FakeMedicationRepository(
|
||||
Medication medication,
|
||||
IReadOnlyList<MedicationLog>? logs = null) : IMedicationRepository
|
||||
{
|
||||
public bool Saved { get; private set; }
|
||||
public List<MedicationLog> AddedLogs { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<Medication>> ListAsync(Guid userId, string? filter, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task<IReadOnlyList<Medication>> ListActiveForRemindersAsync(Guid userId, DateOnly today, CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<IReadOnlyList<MedicationLog>>([]);
|
||||
public Task<IReadOnlyList<MedicationLog>> ListLogsInWindowAsync(Guid userId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(logs ?? (IReadOnlyList<MedicationLog>)[]);
|
||||
public Task<Medication?> GetOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult<Medication?>(medication.UserId == userId && medication.Id == medicationId ? medication : null);
|
||||
public Task<bool> ExistsOwnedAsync(Guid userId, Guid medicationId, CancellationToken ct) => Task.FromResult(medication.UserId == userId && medication.Id == medicationId);
|
||||
public Task<MedicationLog?> GetTodayTakenLogAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||
public Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult(false);
|
||||
public Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct) => Task.FromResult<MedicationLog?>(null);
|
||||
public Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<Medication>>([medication]);
|
||||
public Task AddMedicationAsync(Medication value, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AddLogAsync(MedicationLog log, CancellationToken ct) => Task.CompletedTask;
|
||||
public Task AddLogAsync(MedicationLog log, CancellationToken ct) { AddedLogs.Add(log); return Task.CompletedTask; }
|
||||
public void DeleteMedication(Medication value) { }
|
||||
public void DeleteLog(MedicationLog log) { }
|
||||
public Task SaveChangesAsync(CancellationToken ct) { Saved = true; return Task.CompletedTask; }
|
||||
|
||||
@@ -15,4 +15,33 @@ public sealed class PromptManagerTests
|
||||
Assert.Contains("先回答问题,不调用 record_health_data", prompt);
|
||||
Assert.Contains("帮我记录血压116/89", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnifiedPrompt_RequiresFreshToolsForPersonalStatusAndKeepsArchiveReadOnly()
|
||||
{
|
||||
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
|
||||
|
||||
Assert.Contains("必须先调用对应只读工具", prompt);
|
||||
Assert.Contains("当前工具结果是最高优先级", prompt);
|
||||
Assert.Contains("聊天中不修改健康档案", prompt);
|
||||
Assert.DoesNotContain("健康档案查询/修改", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnifiedPrompt_UsesACommonTemporalIntentModelForPlanQueries()
|
||||
{
|
||||
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
|
||||
|
||||
Assert.Contains("时间意图 + 业务对象", prompt);
|
||||
Assert.Contains("某一天的任务", prompt);
|
||||
Assert.Contains("计划所处的生命周期", prompt);
|
||||
Assert.Contains("不依赖某一句固定问法或某个单独关键词", prompt);
|
||||
Assert.Contains("scope=\"upcoming_plans\"", prompt);
|
||||
Assert.Contains("scope=\"ended_plans\"", prompt);
|
||||
Assert.Contains("scope=\"inactive_plans\"", prompt);
|
||||
Assert.Contains("scope=\"scheduled_date\"", prompt);
|
||||
Assert.Contains("复查使用同一语义映射", prompt);
|
||||
Assert.Contains("scope 必须明确传入", prompt);
|
||||
Assert.DoesNotContain("患者背景只包含当前日期范围内的部分计划", prompt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
# 小脉健康项目交接报告(2026-07-17)
|
||||
|
||||
## 1. 本轮工作范围
|
||||
|
||||
本轮工作集中在以下三项:
|
||||
|
||||
1. 对医生端和管理员端进行患者端风格的轻量统一。
|
||||
2. 检查并修复医生、登录账号、医生资料之间的同步逻辑。
|
||||
3. 进行源码级逻辑检查和小范围自动化验证。
|
||||
|
||||
本轮没有生成 APK、没有进行真机或截图测试、没有启动 Web API、没有部署、没有推送远程代码,也没有修改真实数据库数据。
|
||||
|
||||
## 2. 医生端与管理员端 UI 改造
|
||||
|
||||
### 2.1 共用视觉层
|
||||
|
||||
新增后台共用展示组件:
|
||||
|
||||
- `health_app/lib/widgets/backoffice_ui.dart`
|
||||
- `health_app/lib/utils/backoffice_formatters.dart`
|
||||
- `health_app/lib/providers/backoffice_refresh_providers.dart`
|
||||
|
||||
统一内容包括:
|
||||
|
||||
- 使用患者端现有的紫蓝渐变和浅色页面背景。
|
||||
- 使用白色圆角卡片、轻边框和统一阴影。
|
||||
- 统一加载、空数据、加载失败和重试状态。
|
||||
- 统一管理员端、医生端侧边栏的选中态。
|
||||
- 对空姓名头像和不完整时间字符串进行安全展示。
|
||||
|
||||
### 2.2 医生端
|
||||
|
||||
已覆盖的主要页面:
|
||||
|
||||
- 工作台
|
||||
- 患者列表与患者详情
|
||||
- 问诊列表
|
||||
- 报告列表与报告详情
|
||||
- 随访列表与新增/编辑随访
|
||||
- 医生资料
|
||||
- 医生设置
|
||||
- 医生侧边栏
|
||||
|
||||
已修复的页面问题:
|
||||
|
||||
- “新建随访”以前会被路由器当成缺少 ID 的详情页,现在可以正常进入新建模式。
|
||||
- 新增或编辑随访后,返回列表会自动刷新。
|
||||
- 随访和报告日期不再直接强制截取 16 个字符,避免空值或短字符串导致 `RangeError`。
|
||||
- 空患者姓名不再因为直接读取第一个字符而导致崩溃。
|
||||
- 患者列表加载失败后不再反复自动请求。
|
||||
- 医生资料页面重建时不再反复覆盖用户正在编辑的输入内容。
|
||||
- 停用医生登录后,工作台显示“账号已停用,新患者暂时无法选择您”的提示。
|
||||
|
||||
### 2.3 管理员端
|
||||
|
||||
已覆盖的主要页面:
|
||||
|
||||
- 医生管理
|
||||
- 患者管理
|
||||
- 新增医生
|
||||
- 编辑医生
|
||||
- 管理员侧边栏
|
||||
|
||||
已修复的页面问题:
|
||||
|
||||
- 管理员接口失败时不再无限显示加载状态。
|
||||
- 医生列表增加“编辑”入口,复用新增医生表单。
|
||||
- 新增或编辑医生后,返回列表会自动刷新。
|
||||
- 停用和删除失败时显示后端返回的具体原因,不再静默失败。
|
||||
- 删除确认文案改为安全规则:有绑定患者或问诊记录时只能停用,不能删除。
|
||||
- 停用医生在管理员列表中继续显示“已停用”状态。
|
||||
|
||||
## 3. 医生账号三层数据同步
|
||||
|
||||
医生相关数据由以下三部分组成:
|
||||
|
||||
- `Doctor`:患者选择、医生展示和业务归属。
|
||||
- `User(Role=Doctor)`:医生登录账号。
|
||||
- `DoctorProfile`:医生端资料和医生实体关联。
|
||||
|
||||
本轮修改了 `backend/src/Health.Infrastructure/Admin/AdminService.cs`,规则如下。
|
||||
|
||||
### 3.1 新增医生
|
||||
|
||||
- 一次性创建 `Doctor`、医生登录 `User` 和 `DoctorProfile`。
|
||||
- 三者使用相同的手机号和姓名,并建立正确 ID 关联。
|
||||
- 手机号已被患者、医生或其他账号使用时拒绝新增。
|
||||
|
||||
### 3.2 编辑医生
|
||||
|
||||
- 手机号同步更新 `Doctor` 和登录 `User`。
|
||||
- 姓名同步更新 `Doctor`、登录 `User` 和 `DoctorProfile`。
|
||||
- 职称、科室同步更新 `Doctor` 和 `DoctorProfile`。
|
||||
- 专业方向更新到 `Doctor`。
|
||||
- 修改手机号前检查其他账号和医生是否已经占用。
|
||||
- 医生登录资料缺失时拒绝局部修改,避免继续扩大不一致数据。
|
||||
|
||||
### 3.3 停用医生
|
||||
|
||||
- 同步更新 `Doctor.IsActive` 和 `DoctorProfile.IsActive`。
|
||||
- 医生登录账号保持 `Role=Doctor`,仍可正常登录并服务已绑定患者。
|
||||
- 患者与医生的原有绑定关系保持不变。
|
||||
- 公开医生列表原本已经只查询 `Doctor.IsActive == true`,因此新患者注册时不会再看到已停用医生。
|
||||
- 注册接口原本已经再次校验医生必须有效且启用。
|
||||
|
||||
### 3.4 删除医生
|
||||
|
||||
- 医生仍有绑定患者时拒绝删除。
|
||||
- 医生已有问诊记录时拒绝删除。
|
||||
- 无绑定患者和问诊记录时,删除 `Doctor`、对应登录 `User`、`DoctorProfile` 和刷新令牌。
|
||||
- 有历史数据的医生应使用“停用”,不应通过物理删除清理。
|
||||
|
||||
## 4. 自动化验证
|
||||
|
||||
本轮最终验证结果:
|
||||
|
||||
- `flutter analyze`:通过,零问题。
|
||||
- 后端 `Health.Tests`:56 项通过,0 项失败,0 项跳过。
|
||||
- 医生/管理员 UI、路由和刷新相关测试:通过。
|
||||
|
||||
新增或更新的测试包括:
|
||||
|
||||
- `backend/tests/Health.Tests/admin_service_tests.cs`
|
||||
- `health_app/test/backoffice_ui_test.dart`
|
||||
- `health_app/test/backoffice_formatters_test.dart`
|
||||
- `health_app/test/backoffice_refresh_test.dart`
|
||||
- `health_app/test/app_router_test.dart`
|
||||
|
||||
说明:`dotnet test` 会自动编译测试程序集,但本轮没有执行发布构建,没有生成 Android APK/AAB,也没有执行 iOS 构建。
|
||||
|
||||
## 5. 数据库核对结果
|
||||
|
||||
本轮只进行了只读连接检查,没有修改数据库。
|
||||
|
||||
- 项目本地配置指向 `localhost:5432 / health_manager`。
|
||||
- 本机 PostgreSQL 没有运行,因此无法读取本地实际数据。
|
||||
- 开发 API `http://10.4.237.12:5000` 当前无法连接。
|
||||
- 正式 API `https://erpapi.datalumina.cn/xiaomai/api/doctors` 可以访问,但公开接口返回的启用医生数量为 0。
|
||||
- 交接资料显示正式数据库和备份方案尚未最终配置。
|
||||
|
||||
因此目前无法确认数据库中是否已经存在以下旧数据问题:
|
||||
|
||||
- `Doctor` 存在但没有医生登录 `User`。
|
||||
- `DoctorProfile` 缺失或关联错误。
|
||||
- 三层手机号、姓名或启用状态不一致。
|
||||
- 重复医生手机号。
|
||||
- 患者 `DoctorId` 指向不存在的医生。
|
||||
|
||||
新代码可以防止以后继续产生这些不一致,但不会自动修改已有数据。获得生产 PostgreSQL 只读连接或实际数据库备份后,应单独执行数据核对。
|
||||
|
||||
## 6. 仍未解决的高优先级问题
|
||||
|
||||
### P0:固定管理员验证码
|
||||
|
||||
`backend/src/Health.Infrastructure/Auth/AuthService.cs` 仍保留固定管理员手机号和固定验证码 `000000`。上线前必须移除,管理员需要使用受控的创建和认证方式。
|
||||
|
||||
### P0:短信服务仍是开发实现
|
||||
|
||||
`backend/src/Health.Infrastructure/Services/sms_service.cs` 只向服务端控制台输出验证码,没有接入真实短信服务。生产环境不会把验证码返回 App,因此普通用户无法正常接收验证码。
|
||||
|
||||
### P0:问诊 SignalR Hub 未鉴权
|
||||
|
||||
`backend/src/Health.WebApi/Hubs/ConsultationHub.cs` 没有强制 JWT 身份验证,也没有校验用户或医生是否属于指定问诊。调用者可以传入 `senderType` 和 `senderName`,存在加入其他问诊、伪造医生消息和写入数据库的风险。
|
||||
|
||||
### P1:医生聊天错误复用患者聊天流程
|
||||
|
||||
医生问诊列表进入的 `DoctorChatPage` 复用了患者端 `consultationChatProvider`:
|
||||
|
||||
- 把问诊 ID 当成医生 ID。
|
||||
- 调用患者配额接口。
|
||||
- 尝试创建新问诊。
|
||||
- 发送消息时使用患者身份。
|
||||
- 医生需要回复的 `WaitingDoctor` 状态反而不能发送。
|
||||
|
||||
医生实时问诊目前不能视为可用功能。需要拆分独立医生聊天 Provider 和页面,并与 Hub 鉴权一起处理。
|
||||
|
||||
### P1:AI SSE 鉴权和上传文件隐私
|
||||
|
||||
此前检查发现:
|
||||
|
||||
- AI SSE 接口允许从 query token 中直接读取 JWT claims,没有完整验证签名、签发者、受众和有效期。
|
||||
- 上传的医疗图片和报告通过 `/uploads` 静态公开访问。
|
||||
- 附件本地路径解析缺少完整的目录边界和文件归属校验。
|
||||
- CORS 当前允许任意来源并携带凭据。
|
||||
|
||||
这些问题应在正式上线前统一修复。
|
||||
|
||||
### P1:iOS 蓝牙流程仍需修复和真机验证
|
||||
|
||||
此前检查发现 iOS 页面仍请求 Android 风格蓝牙权限,并调用仅 Android 支持的 `FlutterBluePlus.turnOn()`。iOS Pods 也需要在 macOS 上重新安装并验证。当前 Windows 环境不能证明 iOS 构建和真机蓝牙可用。
|
||||
|
||||
## 7. Apple 医疗硬件审核准备
|
||||
|
||||
如果首版明确只支持欧姆龙 J735,建议准备:
|
||||
|
||||
- J735 当前有效的医疗器械注册或批准材料。
|
||||
- 小脉健康 App 与 J735 的联调测试报告。
|
||||
- 真实 iPhone、J735 和当前版本 App 的完整配对及测量演示视频。
|
||||
- App、审核说明、兼容设备清单和宣传文案全部明确首版支持范围。
|
||||
|
||||
注册证只能证明血压计本身合规,不能替代 App 与硬件联调测试报告和演示视频。
|
||||
|
||||
## 8. 建议后续顺序
|
||||
|
||||
1. 修复固定管理员验证码并接入正式短信服务。
|
||||
2. 拆分医生聊天流程并为 SignalR Hub 增加 JWT、角色和问诊归属校验。
|
||||
3. 修复 AI SSE 鉴权、上传文件访问控制、路径边界和 CORS。
|
||||
4. 获得真实数据库只读连接,核对并修复旧医生账号数据。
|
||||
5. 在 macOS 和真实 iPhone 上修复并验证 iOS 蓝牙流程。
|
||||
6. 准备 J735 监管材料、联调报告和真实设备演示视频。
|
||||
7. 完成正式服务器、数据库、短信、对象存储、域名和 HTTPS 配置后,再进入发布构建和应用商店提交。
|
||||
|
||||
## 9. 当前操作边界
|
||||
|
||||
- 本轮代码修改和本交接报告均未进行新的 Git 提交或远程推送。
|
||||
- 没有执行数据库迁移。
|
||||
- 没有写入、更新或删除真实数据库数据。
|
||||
- 没有生成或上传发布安装包。
|
||||
- 没有启动需要额外关闭的前端或后端常驻服务。
|
||||
526
docs/medical-citations-knowledge-base-current-app-v3.md
Normal file
@@ -0,0 +1,526 @@
|
||||
# 小脉健康医疗引用资料库 V3
|
||||
|
||||
> 仅用于 AI 医疗信息和健康建议的来源引用。
|
||||
>
|
||||
> 当前 App 相关主题:手动健康数据记录、饮食识别、运动计划、用药提醒、医学报告预整理、危险症状安全提醒。
|
||||
|
||||
---
|
||||
|
||||
## 血压和血压记录
|
||||
|
||||
### [SOURCE:S-BP-01]
|
||||
|
||||
来源标题:2024 ESC Guidelines for the management of elevated blood pressure and hypertension
|
||||
|
||||
来源机构:European Society of Cardiology Scientific Document Group
|
||||
|
||||
PubMed:https://pubmed.ncbi.nlm.nih.gov/39210715/
|
||||
|
||||
PMID:39210715
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 血压解释应结合多次记录、测量条件、症状和临床评估;
|
||||
- 单次血压记录不能单独诊断高血压;
|
||||
- 家庭或院外血压记录可以帮助观察一段时间内的变化;
|
||||
- 血压记录应交由医生结合个人情况解释;
|
||||
- 不应根据一次读数自行改变降压药物。
|
||||
|
||||
适用回答:
|
||||
|
||||
- “这次血压记录怎么看”;
|
||||
- “血压偏高/偏低怎么办”;
|
||||
- “需要不要复测”;
|
||||
- “血压趋势如何理解”。
|
||||
|
||||
不支持:
|
||||
|
||||
- 根据一次读数确诊高血压;
|
||||
- 给出个人治疗目标;
|
||||
- 建议加药、减药或停药。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
|
||||
https://pubmed.ncbi.nlm.nih.gov/39210715/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 血糖和低血糖
|
||||
|
||||
### [SOURCE:S-GLU-01]
|
||||
|
||||
来源标题:6. Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes-2025
|
||||
|
||||
来源机构:American Diabetes Association Professional Practice Committee
|
||||
|
||||
PubMed:https://pubmed.ncbi.nlm.nih.gov/39651981/
|
||||
|
||||
PMID:39651981
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 血糖目标需要结合个人情况、低血糖风险、支持条件和治疗目标;
|
||||
- 不能把同一个血糖目标直接套用到所有用户;
|
||||
- 血糖结果应结合测量时间、空腹或餐后状态、既往疾病和用药情况理解;
|
||||
- 血糖异常记录应交给医生结合个人情况评估;
|
||||
- AI 不能根据一次血糖记录诊断糖尿病或决定调整药物。
|
||||
|
||||
适用回答:
|
||||
|
||||
- “这次血糖值怎么理解”;
|
||||
- “空腹/餐后血糖记录”;
|
||||
- “血糖目标是否适合我”;
|
||||
- “血糖偏低或偏高”。
|
||||
|
||||
不支持:
|
||||
|
||||
- 给所有用户设定统一目标;
|
||||
- 诊断糖尿病;
|
||||
- 指导自行调整降糖药。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes-2025
|
||||
https://pubmed.ncbi.nlm.nih.gov/39651981/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 血氧
|
||||
|
||||
### [SOURCE:S-OXY-01]
|
||||
|
||||
来源标题:Pulse Oximeter Basics
|
||||
|
||||
来源机构:U.S. Food and Drug Administration
|
||||
|
||||
来源:https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 血氧仪读数是对血氧水平的估计,不是完整的临床诊断;
|
||||
- 读数应结合症状和变化趋势理解,不能只看单个数字;
|
||||
- 手部温度、活动、循环、皮肤色素、指甲油和设备因素可能影响读数;
|
||||
- 呼吸困难、胸痛、嘴唇或指甲发青等症状需要及时联系医疗服务;
|
||||
- 不能仅凭血氧读数自行调整药物或氧疗。
|
||||
|
||||
适用回答:
|
||||
|
||||
- “血氧记录怎么理解”;
|
||||
- “血氧偏低是否需要复测”;
|
||||
- “血氧数值和症状的关系”;
|
||||
- “为什么同一个人不同时间读数不同”。
|
||||
|
||||
不支持:
|
||||
|
||||
- 仅凭一个血氧值诊断或排除疾病;
|
||||
- 指导自行吸氧、调氧或调药;
|
||||
- 宣称某个单独数值对所有人都绝对安全或危险。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[S-OXY-01] Pulse Oximeter Basics, U.S. FDA
|
||||
https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 饮食和饮食图片识别
|
||||
|
||||
### [SOURCE:S-DIET-01]
|
||||
|
||||
来源标题:Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
|
||||
|
||||
来源机构:American Heart Association Council on Lifestyle and Cardiometabolic Health
|
||||
|
||||
PubMed:https://pubmed.ncbi.nlm.nih.gov/37128940/
|
||||
|
||||
PMID:37128940
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 地中海饮食、DASH 饮食、素食和鱼素饮食等模式总体上与心血管健康饮食原则较为一致;
|
||||
- 饮食模式应考虑个人偏好、文化、预算和长期可执行性;
|
||||
- 饮食图片不能准确确定食物重量、实际份量、烹调油、调味料和完整配方;
|
||||
- AI 识别出的盐、糖、脂肪和热量只能作为一般估计;
|
||||
- 一般饮食信息不能替代针对疾病、过敏或用药情况的营养评估。
|
||||
|
||||
适用回答:
|
||||
|
||||
- “这张图片里有什么食物”;
|
||||
- “这餐可能含盐/糖/脂肪较多吗”;
|
||||
- “这餐热量为什么只能估算”;
|
||||
- “一般心血管健康饮食有哪些原则”。
|
||||
|
||||
不支持:
|
||||
|
||||
- 根据用户疾病档案制定饮食处方;
|
||||
- 说某个食物对所有患者都“绝对不能吃”;
|
||||
- 声称某种饮食可以治疗疾病;
|
||||
- 根据一张图片精确计算摄入量。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
|
||||
https://pubmed.ncbi.nlm.nih.gov/37128940/
|
||||
```
|
||||
|
||||
### [SOURCE:S-DIET-02]
|
||||
|
||||
来源标题:Diet and nutrition in cardiovascular disease prevention
|
||||
|
||||
来源机构:European Society of Cardiology 相关预防心脏病学组织
|
||||
|
||||
PubMed:https://pubmed.ncbi.nlm.nih.gov/40504596/
|
||||
|
||||
PMID:40504596
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 饮食是心血管疾病预防的重要组成部分;
|
||||
- 富含蔬菜水果、较少加工食品的饮食模式总体上更符合心血管预防原则;
|
||||
- 盐、糖、饱和脂肪和高度加工食品应作为饮食评估时的客观因素;
|
||||
- 饮食信息应作为一般健康信息,不应直接替代个体化营养治疗。
|
||||
|
||||
适用回答:
|
||||
|
||||
- 食物营养特征说明;
|
||||
- 饮食记录总结;
|
||||
- 一般心血管饮食知识。
|
||||
|
||||
不支持:
|
||||
|
||||
- 针对某个患者制定每日热量、盐量或营养素处方;
|
||||
- 根据疾病和药物直接判断某道食物“禁止食用”。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[S-DIET-02] Diet and nutrition in cardiovascular disease prevention
|
||||
https://pubmed.ncbi.nlm.nih.gov/40504596/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运动计划
|
||||
|
||||
### [SOURCE:S-EX-01]
|
||||
|
||||
来源标题:Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
|
||||
|
||||
来源机构:American Heart Association
|
||||
|
||||
PubMed:https://pubmed.ncbi.nlm.nih.gov/38059362/
|
||||
|
||||
PMID:38059362
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 抗阻训练可以作为成年人健康活动的一部分;
|
||||
- 运动计划应考虑个人健康状态、运动能力和安全性;
|
||||
- 普通健康信息不能直接变成心血管疾病患者的医学运动处方;
|
||||
- 有胸痛、晕厥、明显呼吸困难或医生限制运动时,应先进行医疗评估。
|
||||
|
||||
适用回答:
|
||||
|
||||
- 创建或记录运动计划;
|
||||
- 解释运动计划的记录内容;
|
||||
- 一般运动安全提醒。
|
||||
|
||||
不支持:
|
||||
|
||||
- 针对心血管疾病制定具体训练处方;
|
||||
- 在有危险症状时建议继续运动;
|
||||
- 根据一次运动记录判断治疗效果。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[S-EX-01] Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update
|
||||
https://pubmed.ncbi.nlm.nih.gov/38059362/
|
||||
```
|
||||
|
||||
### [SOURCE:S-EX-02]
|
||||
|
||||
来源标题:Core Components of Cardiac Rehabilitation Programs: 2024 Update
|
||||
|
||||
来源机构:American Heart Association / American Association of Cardiovascular and Pulmonary Rehabilitation
|
||||
|
||||
PubMed:https://pubmed.ncbi.nlm.nih.gov/39315436/
|
||||
|
||||
PMID:39315436
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 心脏康复是结构化、专业管理的过程;
|
||||
- 运动安排需要结合评估、教育、危险因素管理和随访;
|
||||
- AI 的运动计划记录功能不能替代心脏康复团队。
|
||||
|
||||
适用回答:
|
||||
|
||||
- 心脏康复相关问题;
|
||||
- 已知心血管疾病用户的运动安全提醒;
|
||||
- 建议用户咨询康复专业人员。
|
||||
|
||||
不支持:
|
||||
|
||||
- AI 自行制定心脏康复处方;
|
||||
- 根据聊天内容判断用户可以进行何种运动强度。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[S-EX-02] Core Components of Cardiac Rehabilitation Programs: 2024 Update
|
||||
https://pubmed.ncbi.nlm.nih.gov/39315436/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 用药提醒
|
||||
|
||||
### [SOURCE:S-MED-01]
|
||||
|
||||
来源标题:Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
|
||||
|
||||
来源机构:National Institute for Health and Care Excellence
|
||||
|
||||
PubMed:https://pubmed.ncbi.nlm.nih.gov/39480983/
|
||||
|
||||
PMID:39480983
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 用药依从性需要了解患者的实际困难、偏好和需求;
|
||||
- 共同决策和清晰沟通有助于支持患者执行已经确定的用药方案;
|
||||
- App 可以帮助记录用药计划、查询任务和提供提醒;
|
||||
- 是否开始、停止、更换或调整药物,应由医生或药师决定。
|
||||
|
||||
适用回答:
|
||||
|
||||
- 用药计划记录;
|
||||
- 服药提醒;
|
||||
- 查询某日已保存的用药任务;
|
||||
- 建议用户对药物疑问咨询医生或药师。
|
||||
|
||||
不支持:
|
||||
|
||||
- 停药、换药、加药、减药;
|
||||
- 自行决定漏服后的补服方法;
|
||||
- 根据症状猜测药物副作用或替代治疗。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[S-MED-01] Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence
|
||||
https://pubmed.ncbi.nlm.nih.gov/39480983/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 医学报告预整理
|
||||
|
||||
### [SOURCE:S-REPORT-01]
|
||||
|
||||
来源标题:Interpretating Normal Values and Reference Ranges for Laboratory Tests
|
||||
|
||||
PubMed:https://pubmed.ncbi.nlm.nih.gov/40268322/
|
||||
|
||||
PMID:40268322
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 实验室参考范围会受到检测方法、实验室、人群和生理因素影响;
|
||||
- 报告标注为高或低,不等于已经诊断疾病;
|
||||
- 参考范围内也不能单独排除疾病;
|
||||
- 报告应结合症状、病史和其他检查结果由医生解释;
|
||||
- AI 可以整理报告已有的项目、数值、单位和原始参考范围。
|
||||
|
||||
适用回答:
|
||||
|
||||
- 报告指标提取;
|
||||
- 报告中的高/低标记说明;
|
||||
- 提醒用户核对原报告;
|
||||
- 提醒用户让医生解释报告。
|
||||
|
||||
不支持:
|
||||
|
||||
- 根据一项异常指标推断病因;
|
||||
- 根据影像或检验图片诊断疾病;
|
||||
- 根据 AI 摘要决定治疗。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[S-REPORT-01] Interpretating Normal Values and Reference Ranges for Laboratory Tests
|
||||
https://pubmed.ncbi.nlm.nih.gov/40268322/
|
||||
```
|
||||
|
||||
### [SOURCE:S-REPORT-02]
|
||||
|
||||
来源标题:Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
|
||||
|
||||
PubMed:https://pubmed.ncbi.nlm.nih.gov/29729142/
|
||||
|
||||
PMID:29729142
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 参考区间与检测方法和目标人群有关;
|
||||
- 不同实验室的参考区间不能不加判断地互相替换;
|
||||
- 解释化验结果时应优先使用报告本身提供的参考范围。
|
||||
|
||||
适用回答:
|
||||
|
||||
- “为什么不同报告的参考范围不同”;
|
||||
- “为什么不能直接套用网上正常值”;
|
||||
- “为什么 AI 只能整理而不能诊断”。
|
||||
|
||||
不支持:
|
||||
|
||||
- 把通用范围直接作为 App 对所有用户的诊断标准。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations
|
||||
https://pubmed.ncbi.nlm.nih.gov/29729142/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 危险症状和就医提醒
|
||||
|
||||
### [SOURCE:S-EM-01]
|
||||
|
||||
来源标题:2024 American Heart Association and American Red Cross Guidelines for First Aid
|
||||
|
||||
来源机构:American Heart Association / American Red Cross
|
||||
|
||||
来源:https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 胸部不适或压迫感、呼吸困难、上肢/背部/颈部/下颌不适、恶心、出汗和头晕可能是需要及时评估的危险信号;
|
||||
- 面部下垂、单侧手臂或腿无力、言语障碍、突然视物困难、行走困难、失去平衡或突发严重头痛可能提示卒中危险信号;
|
||||
- 出现突发危险症状时,应立即启动当地急救服务,不要等待 AI 判断;
|
||||
- AI 不能远程确认心梗、卒中或其他急症。
|
||||
|
||||
适用回答:
|
||||
|
||||
- 用户描述胸痛、呼吸困难、单侧无力、言语不清、意识异常等情况;
|
||||
- 健康记录异常同时伴随危险症状;
|
||||
- 固定安全提醒。
|
||||
|
||||
不支持:
|
||||
|
||||
- 远程诊断心梗或卒中;
|
||||
- 用“没有某个症状”排除急症;
|
||||
- 指导用户自行用药处理急症。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[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
|
||||
```
|
||||
|
||||
### [SOURCE:S-EM-02]
|
||||
|
||||
来源标题:Know the warning signs of heart attack and stroke
|
||||
|
||||
来源机构:American Heart Association
|
||||
|
||||
来源:https://international.heart.org/en/-/media/International/Files/Heart-Attack-Stroke-Warning-Signs/HeartAttackandStrokeWarningSignsInternationalEnglish.pdf?sc_lang=en
|
||||
|
||||
可引用内容:
|
||||
|
||||
- 心脏危险信号包括胸部不适、呼吸困难、上肢/背部/颈部/下颌不适、冷汗、恶心或头晕;
|
||||
- 卒中可以使用 FAST 思路识别:面部下垂、手臂无力、言语困难、立即呼叫急救;
|
||||
- 症状即使短暂消失,也应及时寻求医疗帮助。
|
||||
|
||||
适用回答:
|
||||
|
||||
- 心脏危险信号;
|
||||
- 卒中危险信号;
|
||||
- “是否应该立即就医”的安全提醒。
|
||||
|
||||
不支持:
|
||||
|
||||
- 仅凭文字排除急症;
|
||||
- 让用户等待 AI 继续分析;
|
||||
- 把当地急救号码固定写成 911。
|
||||
|
||||
推荐引用格式:
|
||||
|
||||
```text
|
||||
参考来源:
|
||||
[S-EM-02] Know the warning signs of heart attack and stroke
|
||||
https://international.heart.org/en/-/media/International/Files/Heart-Attack-Stroke-Warning-Signs/HeartAttackandStrokeWarningSignsInternationalEnglish.pdf?sc_lang=en
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 引用使用示例
|
||||
|
||||
### 示例一:血压记录
|
||||
|
||||
```text
|
||||
这条记录是收缩压 X mmHg、舒张压 Y mmHg。单次记录不能单独用于诊断,建议结合连续记录和身体症状交给医生评估。
|
||||
|
||||
参考来源:
|
||||
[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension
|
||||
https://pubmed.ncbi.nlm.nih.gov/39210715/
|
||||
```
|
||||
|
||||
### 示例二:饮食图片
|
||||
|
||||
```text
|
||||
根据图片,这餐可能含有较多盐或脂肪,但图片无法准确判断实际份量、调味料和烹调油,因此只能作为一般营养估计,不是个性化饮食处方。
|
||||
|
||||
参考来源:
|
||||
[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance
|
||||
https://pubmed.ncbi.nlm.nih.gov/37128940/
|
||||
```
|
||||
|
||||
### 示例三:报告结果
|
||||
|
||||
```text
|
||||
报告中该项目被标记为偏高。不同实验室和检测方法的参考范围可能不同,报告中的异常标记不等于疾病诊断,请结合原报告和医生意见判断。
|
||||
|
||||
参考来源:
|
||||
[S-REPORT-01] Interpretating Normal Values and Reference Ranges for Laboratory Tests
|
||||
https://pubmed.ncbi.nlm.nih.gov/40268322/
|
||||
```
|
||||
|
||||
### 示例四:危险症状
|
||||
|
||||
```text
|
||||
你描述的症状可能需要及时进行医疗评估。请立即联系当地急救服务或前往急诊,不要等待 AI 继续分析,也不要自行开车。
|
||||
|
||||
参考来源:
|
||||
[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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
版本:V3
|
||||
|
||||
整理日期:2026-07-26
|
||||
@@ -36,7 +36,7 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
// 正式签名配置
|
||||
// 正式签名配置:key.properties 不存在时回退到 debug keystore,仅用于本地 release 测试
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
@@ -46,6 +46,11 @@ android {
|
||||
file(path)
|
||||
}
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
} else {
|
||||
keyAlias = "androiddebugkey"
|
||||
keyPassword = "android"
|
||||
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
|
||||
storePassword = "android"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
|
||||
BIN
health_app/assets/branding/agent_illustration_consultation.png
Normal file
|
After Width: | Height: | Size: 994 KiB |
BIN
health_app/assets/branding/agent_illustration_diet.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
health_app/assets/branding/agent_illustration_exercise.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/agent_illustration_health.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
health_app/assets/branding/agent_illustration_medication.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
health_app/assets/branding/agent_illustration_report.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
health_app/assets/branding/trend_metric_blood_pressure.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/trend_metric_glucose.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
health_app/assets/branding/trend_metric_heart_rate.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/trend_metric_spo2.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/trend_metric_weight.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -70,9 +70,9 @@
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
|
||||
@@ -26,12 +26,10 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>需要使用蓝牙连接支持的血压计等健康设备,以同步用户主动测量的健康数据</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>需要使用蓝牙连接支持的血压计等健康设备</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要使用麦克风将你按住说出的健康问题转换为文字并发送给小脉健康助手</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要保存图片到相册</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
|
||||
@@ -5,10 +5,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'core/app_router.dart';
|
||||
import 'core/app_theme.dart';
|
||||
import 'core/elder_mode_scope.dart';
|
||||
import 'core/navigation_provider.dart';
|
||||
import 'pages/splash_page.dart';
|
||||
import 'providers/auth_provider.dart';
|
||||
import 'providers/data_providers.dart';
|
||||
import 'providers/elder_mode_provider.dart';
|
||||
|
||||
/// 健康管家 App 根组件
|
||||
class HealthApp extends ConsumerWidget {
|
||||
@@ -16,10 +18,16 @@ class HealthApp extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authProvider);
|
||||
final elderMode = ref.watch(elderModeProvider);
|
||||
final isPatient = auth.isLoggedIn && auth.user?.role == 'User';
|
||||
final useElderMode = isPatient && elderMode.isEnabled;
|
||||
final useLargeText = useElderMode && elderMode.preferences.largeTextEnabled;
|
||||
|
||||
return MaterialApp(
|
||||
title: '小脉健康',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
theme: useElderMode ? AppTheme.elderLightTheme : AppTheme.lightTheme,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
@@ -30,12 +38,23 @@ class HealthApp extends ConsumerWidget {
|
||||
home: const _RootNavigator(),
|
||||
// 注入 ShadTheme + 启动闸门:Splash 盖在最上层,直到首页今日健康卡数据就绪
|
||||
// 外层包一层 GestureDetector:点击任意空白处取消输入框焦点(全 app 生效)
|
||||
builder: (context, child) => ShadTheme(
|
||||
data: AppTheme.shadTheme,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
child: _BootGate(child: child!),
|
||||
builder: (context, child) => ElderModeScope(
|
||||
enabled: useElderMode,
|
||||
largeTextEnabled: useLargeText,
|
||||
child: Builder(
|
||||
builder: (scopeContext) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
scopeContext,
|
||||
).copyWith(textScaler: ElderModeScope.textScalerFor(scopeContext)),
|
||||
child: ShadTheme(
|
||||
data: AppTheme.shadTheme,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
child: _BootGate(child: child!),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -52,6 +71,7 @@ final appReadyProvider = Provider<bool>((ref) {
|
||||
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
|
||||
final role = auth.user?.role ?? 'User';
|
||||
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
|
||||
if (!ref.watch(elderModeProvider).isLoaded) return false;
|
||||
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
|
||||
final health = ref.watch(latestHealthProvider);
|
||||
return health.hasValue || health.hasError;
|
||||
@@ -74,11 +94,28 @@ class _BootGateState extends ConsumerState<_BootGate> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTimeout();
|
||||
}
|
||||
|
||||
void _startTimeout() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(const Duration(seconds: 8), () {
|
||||
if (mounted) setState(() => _timedOut = true);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleReadyChanged(bool? previous, bool ready) {
|
||||
if (ready) {
|
||||
_timer?.cancel();
|
||||
if (_timedOut) setState(() => _timedOut = false);
|
||||
return;
|
||||
}
|
||||
if (previous != false) {
|
||||
if (_timedOut) setState(() => _timedOut = false);
|
||||
_startTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
@@ -87,6 +124,7 @@ class _BootGateState extends ConsumerState<_BootGate> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<bool>(appReadyProvider, _handleReadyChanged);
|
||||
final ready = ref.watch(appReadyProvider) || _timedOut;
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -135,9 +173,21 @@ class _RootNavigator extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: stack.length <= 1,
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) popRoute(ref);
|
||||
if (didPop) return;
|
||||
if (stack.length > 1) {
|
||||
popRoute(ref);
|
||||
return;
|
||||
}
|
||||
final homeRoute = switch (authState.user?.role) {
|
||||
'Admin' => 'adminHome',
|
||||
'Doctor' => 'doctorHome',
|
||||
_ => 'home',
|
||||
};
|
||||
if (current.name != homeRoute && authState.isLoggedIn) {
|
||||
goRoute(ref, homeRoute);
|
||||
}
|
||||
},
|
||||
child: Navigator(
|
||||
pages: [
|
||||
@@ -148,8 +198,12 @@ class _RootNavigator extends ConsumerWidget {
|
||||
child: buildPage(stack[index], ref),
|
||||
),
|
||||
],
|
||||
onDidRemovePage: (_) {
|
||||
if (ref.read(routeStackProvider).length > 1) popRoute(ref);
|
||||
onDidRemovePage: (page) {
|
||||
final routes = ref.read(routeStackProvider);
|
||||
if (routes.length <= 1) return;
|
||||
final lastIndex = routes.length - 1;
|
||||
final currentPageKey = ValueKey(_pageKey(routes.last, lastIndex));
|
||||
if (page.key == currentPageKey) popRoute(ref);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: kReleaseMode
|
||||
? 'https://erpapi.datalumina.cn/xiaomai'
|
||||
: 'http://10.4.159.130:5000',
|
||||
: 'http://192.168.1.34:5000',
|
||||
);
|
||||
|
||||
class ApiException implements Exception {
|
||||
@@ -44,6 +44,7 @@ class ApiClient {
|
||||
final Dio _dio;
|
||||
final LocalDatabase _db;
|
||||
final AuthExpiredNotifier? _authExpiredNotifier;
|
||||
Future<_TokenRefreshResult>? _refreshInFlight;
|
||||
|
||||
ApiClient({
|
||||
required LocalDatabase db,
|
||||
@@ -79,6 +80,63 @@ class ApiClient {
|
||||
await _db.delete('refresh_token');
|
||||
}
|
||||
|
||||
Future<void> clearTokensIfRefreshMatches(String failedRefreshToken) async {
|
||||
if (await refreshToken != failedRefreshToken) return;
|
||||
await clearTokens();
|
||||
}
|
||||
|
||||
Future<_TokenRefreshResult> _refreshTokens() {
|
||||
final active = _refreshInFlight;
|
||||
if (active != null) return active;
|
||||
final future = _performTokenRefresh();
|
||||
_refreshInFlight = future;
|
||||
return future.whenComplete(() {
|
||||
if (identical(_refreshInFlight, future)) _refreshInFlight = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<_TokenRefreshResult> _performTokenRefresh() async {
|
||||
final refresh = await refreshToken;
|
||||
if (refresh == null) return const _TokenRefreshResult.invalid();
|
||||
try {
|
||||
final response = await Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
),
|
||||
).post('/api/auth/refresh', data: {'refreshToken': refresh});
|
||||
final body = response.data;
|
||||
final data = body is Map ? body['data'] : null;
|
||||
final rawCode = body is Map ? body['code'] : null;
|
||||
final code = rawCode is int ? rawCode : int.tryParse('$rawCode');
|
||||
if (code != null && code != 0) {
|
||||
return _TokenRefreshResult.invalid(refreshToken: refresh);
|
||||
}
|
||||
if (data is Map &&
|
||||
data['accessToken'] is String &&
|
||||
data['refreshToken'] is String) {
|
||||
final accessToken = data['accessToken'] as String;
|
||||
final newRefreshToken = data['refreshToken'] as String;
|
||||
// 刷新期间用户可能已经退出或切换账号,旧响应不得覆盖新会话。
|
||||
if (await refreshToken != refresh) {
|
||||
return const _TokenRefreshResult.transientFailure();
|
||||
}
|
||||
await saveTokens(accessToken, newRefreshToken);
|
||||
return _TokenRefreshResult.success(accessToken);
|
||||
}
|
||||
return const _TokenRefreshResult.transientFailure();
|
||||
} on DioException catch (error) {
|
||||
if (error.response?.statusCode == 400 ||
|
||||
error.response?.statusCode == 401) {
|
||||
return _TokenRefreshResult.invalid(refreshToken: refresh);
|
||||
}
|
||||
return const _TokenRefreshResult.transientFailure();
|
||||
} catch (_) {
|
||||
return const _TokenRefreshResult.transientFailure();
|
||||
}
|
||||
}
|
||||
|
||||
void notifyAuthExpired() {
|
||||
_authExpiredNotifier?.notify();
|
||||
}
|
||||
@@ -202,31 +260,51 @@ class _AuthInterceptor extends Interceptor {
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (err.response?.statusCode == 401) {
|
||||
final refresh = await _client.refreshToken;
|
||||
if (refresh != null) {
|
||||
try {
|
||||
final response = await Dio(
|
||||
BaseOptions(baseUrl: baseUrl),
|
||||
).post('/api/auth/refresh', data: {'refreshToken': refresh});
|
||||
final data = response.data['data'];
|
||||
if (data != null) {
|
||||
await _client.saveTokens(data['accessToken'], data['refreshToken']);
|
||||
final opts = err.requestOptions;
|
||||
final token = data['accessToken'];
|
||||
opts.headers['Authorization'] = 'Bearer $token';
|
||||
final retryResponse = await Dio(
|
||||
BaseOptions(baseUrl: baseUrl),
|
||||
).fetch(opts);
|
||||
return handler.resolve(retryResponse);
|
||||
}
|
||||
} catch (e) {
|
||||
log('[ApiClient] token刷新失败: $e');
|
||||
}
|
||||
if (err.response?.statusCode != 401 ||
|
||||
err.requestOptions.extra['authRetried'] == true) {
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
final result = await _client._refreshTokens();
|
||||
if (result.accessToken != null) {
|
||||
try {
|
||||
final opts = err.requestOptions;
|
||||
opts.extra['authRetried'] = true;
|
||||
opts.headers['Authorization'] = 'Bearer ${result.accessToken}';
|
||||
final retryResponse = await _client.dio.fetch(opts);
|
||||
return handler.resolve(retryResponse);
|
||||
} catch (error) {
|
||||
log('[ApiClient] 请求重试失败: $error');
|
||||
}
|
||||
} else if (result.isInvalid) {
|
||||
final failedRefresh = result.failedRefreshToken;
|
||||
if (failedRefresh != null) {
|
||||
await _client.clearTokensIfRefreshMatches(failedRefresh);
|
||||
} else {
|
||||
await _client.clearTokens();
|
||||
}
|
||||
await _client.clearTokens();
|
||||
_client.notifyAuthExpired();
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
class _TokenRefreshResult {
|
||||
final String? accessToken;
|
||||
final bool isInvalid;
|
||||
final String? failedRefreshToken;
|
||||
|
||||
const _TokenRefreshResult._({
|
||||
this.accessToken,
|
||||
this.isInvalid = false,
|
||||
this.failedRefreshToken,
|
||||
});
|
||||
|
||||
const _TokenRefreshResult.success(String accessToken)
|
||||
: this._(accessToken: accessToken);
|
||||
|
||||
const _TokenRefreshResult.invalid({String? refreshToken})
|
||||
: this._(isInvalid: true, failedRefreshToken: refreshToken);
|
||||
|
||||
const _TokenRefreshResult.transientFailure() : this._();
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ class AppColors {
|
||||
static const Color medication = Color(0xFF00B8D9);
|
||||
static const Color medicationLight = Color(0xFFEAFBFF);
|
||||
static const Color medicationBorder = Color(0xFFB8F0FA);
|
||||
static const Color exercise = Color(0xFF566FD4);
|
||||
static const Color exerciseLight = Color(0xFFF1F3FD);
|
||||
static const Color exerciseBorder = Color(0xFFD7DCF6);
|
||||
static const Color exercise = Color(0xFF7C3AED);
|
||||
static const Color exerciseLight = Color(0xFFF3E8FF);
|
||||
static const Color exerciseBorder = Color(0xFFDDD6FE);
|
||||
static const Color report = Color(0xFF6366F1);
|
||||
static const Color reportLight = Color(0xFFEFFBFF);
|
||||
static const Color reportBorder = Color(0xFFBAE6FD);
|
||||
@@ -168,7 +168,7 @@ class AppColors {
|
||||
static const LinearGradient healthGradient = LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Color(0xFF2FAE9B), Color(0xFF63BFAE)],
|
||||
colors: [Color(0xFF43E97B), Color(0xFF38F9D7)],
|
||||
);
|
||||
|
||||
static const LinearGradient medicationGradient = LinearGradient(
|
||||
@@ -180,7 +180,7 @@ class AppColors {
|
||||
static const LinearGradient exerciseGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF5B7CDE), Color(0xFF7569D9)],
|
||||
colors: [Color(0xFFC4B5FD), Color(0xFF7C3AED)],
|
||||
);
|
||||
|
||||
static const LinearGradient reportGradient = LinearGradient(
|
||||
|
||||
@@ -95,7 +95,7 @@ class AppModuleVisuals {
|
||||
static const diet = AppModuleVisual(
|
||||
module: AppModule.diet,
|
||||
label: '饮食',
|
||||
icon: LucideIcons.utensils,
|
||||
icon: LucideIcons.salad,
|
||||
color: AppColors.diet,
|
||||
lightColor: AppColors.dietLight,
|
||||
borderColor: AppColors.dietBorder,
|
||||
@@ -168,3 +168,20 @@ class AppModuleVisuals {
|
||||
|
||||
static AppModuleVisual of(AppModule module) => values[module] ?? ai;
|
||||
}
|
||||
|
||||
/// 健康概览及所有健康指标卡片共用的图标与颜色。
|
||||
class HealthMetricVisuals {
|
||||
HealthMetricVisuals._();
|
||||
|
||||
static const bloodPressureIcon = Icons.bloodtype_outlined;
|
||||
static const heartRateIcon = LucideIcons.heartPulse;
|
||||
static const glucoseIcon = Icons.water_drop_outlined;
|
||||
static const spo2Icon = Icons.air_outlined;
|
||||
static const weightIcon = Icons.monitor_weight_outlined;
|
||||
|
||||
static const bloodPressureColor = Color(0xFF8B5CF6);
|
||||
static const heartRateColor = Color(0xFFF43F5E);
|
||||
static const glucoseColor = Color(0xFFF59E0B);
|
||||
static const spo2Color = Color(0xFF0EA5E9);
|
||||
static const weightColor = Color(0xFF10B981);
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@ import '../pages/report/report_pages.dart';
|
||||
import '../pages/report/ai_analysis_page.dart';
|
||||
import '../pages/consultation/consultation_pages.dart';
|
||||
import '../pages/settings/settings_pages.dart';
|
||||
import '../pages/settings/elder_mode_page.dart';
|
||||
import '../pages/settings/notification_prefs_page.dart';
|
||||
import '../pages/settings/ai_consent_details_page.dart';
|
||||
import '../pages/notifications/notification_center_page.dart';
|
||||
import '../pages/history/conversation_history_page.dart';
|
||||
import '../pages/profile/profile_page.dart';
|
||||
import '../pages/profile/profile_edit_page.dart';
|
||||
import '../pages/diet/diet_capture_page.dart';
|
||||
import '../pages/exercise/exercise_plan_page.dart';
|
||||
import '../pages/device/device_scan_page.dart';
|
||||
import '../pages/device/device_management_page.dart';
|
||||
// iOS 审核版:蓝牙设备页已移除
|
||||
// import '../pages/device/device_scan_page.dart';
|
||||
// import '../pages/device/device_management_page.dart';
|
||||
import '../pages/remaining_pages.dart';
|
||||
import '../pages/doctor/doctor_home_page.dart';
|
||||
import '../pages/doctor/doctor_patient_detail_page.dart';
|
||||
@@ -31,6 +34,7 @@ import '../pages/admin/admin_home_page.dart';
|
||||
import '../pages/admin/admin_add_doctor_page.dart';
|
||||
import '../providers/auth_provider.dart' show userRoleProvider;
|
||||
import '../widgets/app_error_state.dart';
|
||||
import '../widgets/ai_consent_gate.dart';
|
||||
|
||||
Widget _missingParamPage() {
|
||||
return const Scaffold(
|
||||
@@ -51,7 +55,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
return const LoginPage();
|
||||
case 'home':
|
||||
final role = ref.watch(userRoleProvider);
|
||||
return role == 'Doctor' ? const DoctorHomePage() : const HomePage();
|
||||
return role == 'Doctor'
|
||||
? const DoctorHomePage()
|
||||
: AiConsentGate(child: const HomePage());
|
||||
case 'doctorHome':
|
||||
return const DoctorHomePage();
|
||||
case 'adminHome':
|
||||
@@ -117,18 +123,21 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
|
||||
case 'doctorFollowUpEdit':
|
||||
return DoctorFollowUpEditPage(id: params['id']);
|
||||
case 'devices':
|
||||
return const DeviceManagementPage();
|
||||
case 'deviceScan':
|
||||
return const DeviceScanPage();
|
||||
// iOS 审核版:蓝牙设备路由已移除
|
||||
// case 'devices': return const DeviceManagementPage();
|
||||
// case 'deviceScan': return const DeviceScanPage();
|
||||
case 'healthArchive':
|
||||
return const HealthArchivePage();
|
||||
case 'followups':
|
||||
return const FollowUpListPage();
|
||||
case 'settings':
|
||||
return const SettingsPage();
|
||||
case 'elderMode':
|
||||
return const ElderModePage();
|
||||
case 'notificationPrefs':
|
||||
return const NotificationPrefsPage();
|
||||
case 'aiConsentDetails':
|
||||
return const AiConsentDetailsPage();
|
||||
case 'notifications':
|
||||
return const NotificationCenterPage();
|
||||
case 'conversationHistory':
|
||||
|
||||
@@ -548,6 +548,7 @@ class GradientScaffold extends StatelessWidget {
|
||||
final Widget? drawer;
|
||||
final Widget? bottomNavigationBar;
|
||||
final bool extendBody;
|
||||
final bool resizeToAvoidBottomInset;
|
||||
|
||||
const GradientScaffold({
|
||||
super.key,
|
||||
@@ -557,6 +558,7 @@ class GradientScaffold extends StatelessWidget {
|
||||
this.drawer,
|
||||
this.bottomNavigationBar,
|
||||
this.extendBody = false,
|
||||
this.resizeToAvoidBottomInset = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -572,6 +574,7 @@ class GradientScaffold extends StatelessWidget {
|
||||
drawer: drawer,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
extendBody: extendBody,
|
||||
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -773,6 +776,74 @@ class AppTheme {
|
||||
),
|
||||
);
|
||||
|
||||
/// 长辈模式只在已登录的患者端启用。这里提高主题级控件尺寸,页面中写死的
|
||||
/// 字号再由 ElderModeScope 提供的文本缩放统一兜底。
|
||||
static ThemeData get elderLightTheme {
|
||||
final base = lightTheme;
|
||||
return base.copyWith(
|
||||
appBarTheme: base.appBarTheme.copyWith(
|
||||
toolbarHeight: 60,
|
||||
titleTextStyle: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: text,
|
||||
),
|
||||
iconTheme: const IconThemeData(size: 28, color: text),
|
||||
),
|
||||
textTheme: base.textTheme.copyWith(
|
||||
headlineLarge: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: text,
|
||||
),
|
||||
titleLarge: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: text,
|
||||
),
|
||||
titleMedium: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: text,
|
||||
),
|
||||
bodyLarge: const TextStyle(fontSize: 18, color: text, height: 1.5),
|
||||
bodyMedium: const TextStyle(fontSize: 16, color: textSub, height: 1.45),
|
||||
labelMedium: const TextStyle(fontSize: 16, color: textSub),
|
||||
labelSmall: const TextStyle(fontSize: 15, color: textHint),
|
||||
),
|
||||
inputDecorationTheme: base.inputDecorationTheme.copyWith(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 16,
|
||||
),
|
||||
hintStyle: const TextStyle(color: textHint, fontSize: 17),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(rLg),
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: text,
|
||||
minimumSize: const Size(52, 56),
|
||||
side: const BorderSide(color: border),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(rMd),
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ShadThemeData get shadTheme => ShadThemeData(
|
||||
brightness: Brightness.light,
|
||||
colorScheme: ShadVioletColorScheme.light(
|
||||
|
||||
42
health_app/lib/core/elder_mode_scope.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ElderModeScope extends InheritedWidget {
|
||||
final bool enabled;
|
||||
final bool largeTextEnabled;
|
||||
|
||||
const ElderModeScope({
|
||||
super.key,
|
||||
required this.enabled,
|
||||
required this.largeTextEnabled,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static ElderModeScope? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<ElderModeScope>();
|
||||
}
|
||||
|
||||
static bool enabledOf(BuildContext context) {
|
||||
return maybeOf(context)?.enabled == true;
|
||||
}
|
||||
|
||||
static bool largeTextOf(BuildContext context) {
|
||||
final scope = maybeOf(context);
|
||||
return scope?.enabled == true && scope?.largeTextEnabled == true;
|
||||
}
|
||||
|
||||
static TextScaler textScalerFor(BuildContext context) {
|
||||
final systemScaler = MediaQuery.textScalerOf(context);
|
||||
if (!largeTextOf(context)) return systemScaler;
|
||||
final systemFactor = systemScaler.scale(1);
|
||||
final factor = math.max(systemFactor, 1.18).clamp(1.0, 1.6).toDouble();
|
||||
return TextScaler.linear(factor);
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(ElderModeScope oldWidget) {
|
||||
return enabled != oldWidget.enabled ||
|
||||
largeTextEnabled != oldWidget.largeTextEnabled;
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
|
||||
import 'bp_reading.dart';
|
||||
|
||||
enum BleDeviceType { bloodPressure, glucose, weightScale, pulseOximeter }
|
||||
|
||||
enum SupportedMetric { bloodPressure, heartRate, glucose, weight, spo2 }
|
||||
|
||||
extension BleDeviceTypeX on BleDeviceType {
|
||||
String get code => switch (this) {
|
||||
BleDeviceType.bloodPressure => 'bloodPressure',
|
||||
BleDeviceType.glucose => 'glucose',
|
||||
BleDeviceType.weightScale => 'weightScale',
|
||||
BleDeviceType.pulseOximeter => 'pulseOximeter',
|
||||
};
|
||||
|
||||
String get label => switch (this) {
|
||||
BleDeviceType.bloodPressure => '血压计',
|
||||
BleDeviceType.glucose => '血糖仪',
|
||||
BleDeviceType.weightScale => '体重秤',
|
||||
BleDeviceType.pulseOximeter => '血氧仪',
|
||||
};
|
||||
|
||||
String get serviceUuid => switch (this) {
|
||||
BleDeviceType.bloodPressure => BleDeviceIdentifier.bloodPressureServiceUuid,
|
||||
BleDeviceType.glucose => BleDeviceIdentifier.glucoseServiceUuid,
|
||||
BleDeviceType.weightScale => BleDeviceIdentifier.weightScaleServiceUuid,
|
||||
BleDeviceType.pulseOximeter => BleDeviceIdentifier.pulseOximeterServiceUuid,
|
||||
};
|
||||
|
||||
List<SupportedMetric> get metrics => switch (this) {
|
||||
BleDeviceType.bloodPressure => [
|
||||
SupportedMetric.bloodPressure,
|
||||
SupportedMetric.heartRate,
|
||||
],
|
||||
BleDeviceType.glucose => [SupportedMetric.glucose],
|
||||
BleDeviceType.weightScale => [SupportedMetric.weight],
|
||||
BleDeviceType.pulseOximeter => [
|
||||
SupportedMetric.spo2,
|
||||
SupportedMetric.heartRate,
|
||||
],
|
||||
};
|
||||
|
||||
IconData get icon => switch (this) {
|
||||
BleDeviceType.bloodPressure => Icons.speed_rounded,
|
||||
BleDeviceType.glucose => Icons.water_drop_rounded,
|
||||
BleDeviceType.weightScale => Icons.monitor_weight_outlined,
|
||||
BleDeviceType.pulseOximeter => Icons.air_rounded,
|
||||
};
|
||||
|
||||
static BleDeviceType? fromCode(String? code) {
|
||||
for (final type in BleDeviceType.values) {
|
||||
if (type.code == code) return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extension SupportedMetricX on SupportedMetric {
|
||||
String get code => switch (this) {
|
||||
SupportedMetric.bloodPressure => 'BloodPressure',
|
||||
SupportedMetric.heartRate => 'HeartRate',
|
||||
SupportedMetric.glucose => 'Glucose',
|
||||
SupportedMetric.weight => 'Weight',
|
||||
SupportedMetric.spo2 => 'SpO2',
|
||||
};
|
||||
|
||||
String get label => switch (this) {
|
||||
SupportedMetric.bloodPressure => '血压',
|
||||
SupportedMetric.heartRate => '心率',
|
||||
SupportedMetric.glucose => '血糖',
|
||||
SupportedMetric.weight => '体重',
|
||||
SupportedMetric.spo2 => '血氧',
|
||||
};
|
||||
}
|
||||
|
||||
class BoundBleDevice {
|
||||
final String id;
|
||||
final String name;
|
||||
final BleDeviceType type;
|
||||
final String serviceUuid;
|
||||
final DateTime? lastSyncAt;
|
||||
|
||||
const BoundBleDevice({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.serviceUuid,
|
||||
this.lastSyncAt,
|
||||
});
|
||||
|
||||
List<SupportedMetric> get metrics => type.metrics;
|
||||
|
||||
BoundBleDevice copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
BleDeviceType? type,
|
||||
String? serviceUuid,
|
||||
DateTime? lastSyncAt,
|
||||
}) {
|
||||
return BoundBleDevice(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
serviceUuid: serviceUuid ?? this.serviceUuid,
|
||||
lastSyncAt: lastSyncAt ?? this.lastSyncAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'type': type.code,
|
||||
'serviceUuid': serviceUuid,
|
||||
'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
static BoundBleDevice? fromJson(Map<String, dynamic> json) {
|
||||
final type = BleDeviceTypeX.fromCode(json['type']?.toString());
|
||||
final id = json['id']?.toString();
|
||||
if (type == null || id == null || id.isEmpty) return null;
|
||||
|
||||
final lastSyncRaw = json['lastSyncAt']?.toString();
|
||||
return BoundBleDevice(
|
||||
id: id,
|
||||
name: json['name']?.toString() ?? type.label,
|
||||
type: type,
|
||||
serviceUuid: json['serviceUuid']?.toString() ?? type.serviceUuid,
|
||||
lastSyncAt: lastSyncRaw == null || lastSyncRaw.isEmpty
|
||||
? null
|
||||
: DateTime.tryParse(lastSyncRaw)?.toLocal(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BleDeviceIdentifier {
|
||||
static const glucoseServiceUuid = '00001808-0000-1000-8000-00805F9B34FB';
|
||||
static const bloodPressureServiceUuid =
|
||||
'00001810-0000-1000-8000-00805F9B34FB';
|
||||
static const weightScaleServiceUuid = '0000181D-0000-1000-8000-00805F9B34FB';
|
||||
static const pulseOximeterServiceUuid =
|
||||
'00001822-0000-1000-8000-00805F9B34FB';
|
||||
|
||||
static BleDeviceType? typeFromScan(ScanResult result) {
|
||||
return typeFromUuidStrings(
|
||||
result.advertisementData.serviceUuids.map((uuid) => uuid.str128),
|
||||
);
|
||||
}
|
||||
|
||||
static BleDeviceType? typeFromServices(List<BluetoothService> services) {
|
||||
return typeFromUuidStrings(services.map((service) => service.uuid.str128));
|
||||
}
|
||||
|
||||
static BleDeviceType? typeFromUuidStrings(Iterable<String> uuids) {
|
||||
final normalized = uuids.map((uuid) => uuid.toUpperCase()).toSet();
|
||||
if (normalized.contains(bloodPressureServiceUuid)) {
|
||||
return BleDeviceType.bloodPressure;
|
||||
}
|
||||
if (normalized.contains(glucoseServiceUuid)) {
|
||||
return BleDeviceType.glucose;
|
||||
}
|
||||
if (normalized.contains(weightScaleServiceUuid)) {
|
||||
return BleDeviceType.weightScale;
|
||||
}
|
||||
if (normalized.contains(pulseOximeterServiceUuid)) {
|
||||
return BleDeviceType.pulseOximeter;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool isSupportedScanResult(ScanResult result) {
|
||||
return typeFromScan(result) != null;
|
||||
}
|
||||
}
|
||||
|
||||
sealed class HealthBleSyncResult {
|
||||
const HealthBleSyncResult({required this.device});
|
||||
|
||||
final BoundBleDevice device;
|
||||
}
|
||||
|
||||
class BloodPressureSyncResult extends HealthBleSyncResult {
|
||||
const BloodPressureSyncResult({required super.device, required this.reading});
|
||||
|
||||
final BpReading reading;
|
||||
}
|
||||