BodySense · V2 领域模型重构开发笔记
按重构版本记录 BodyState、Diagnosis、Training、Safety、Person/Chat 与资产模型的方案比较和领域边界。
领域模型重构目标
BodySense 不只是“和 AI 聊天,然后得到一个诊断和训练建议”。用户长期会留下症状、身体观察、病情判断、训练处方、真实执行和报告材料。这里最重要的问题是:哪种信息属于哪个领域、谁有权修改它、怎样保留来源与历史,以及 AI 的建议何时才算真正生效。
希望整个产品的体验仍然自然:用户可以在 Chat 描述身体情况,通过 Diagnosis 查看分析,通过 Training 执行计划,在 Person 管理记录与资产。但不能因为这些功能出现在同一个页面或一轮对话里,就把它们存成一份混合的“健康档案”。
本文版本号用于组织建模阶段,不代表 BodySense 的正式发布版本。每个阶段只记录该阶段正在解决的问题和当下采用的方案。
V1.0.0
先把主要流程连起来:Consultation 采集 → BodyState 记录 → Diagnosis 分析 → Treatment 提案 → Training 执行 → 反馈回到 BodyState。
最初比较直观的办法,是让 Agent 从 Conversation 里读取所有聊天历史,再生成诊断与训练建议。但这种方式有三个问题:聊天越长,上下文越难维护;模型容易将用户陈述和自己的推断混在一起;用户更新信息后,旧的诊断、训练建议又缺少明确的证据版本。
所以先尝试以下边界:
- BodyState:保存能够长期使用的身体信息,并带有修订历史,不用聊天消息列表直接充当身体档案。
- DiagnosisAnalysis:在某次明确的 BodyState 证据快照上推理,可以列出多个候选判断、支持/反对证据和信息缺口;不让一个模型回答直接成为医学事实。
- Treatment / Training:训练建议要先形成方案,用户接受后才成为有效处方;真实执行与后续身体反馈独立记录。
- Safety Gateway:风险筛查优先于普通建议。模型可以识别风险线索,但不能独自决定“可以安全训练”。
这一步的重点是建立事实—分析—行动—结果的闭环,而不是马上为每个页面创建一套数据库表。
v1.1.0
现在需要让历史和来源更可靠。用户说“上个月左膝痛”和“之前说错了,其实是右膝痛”,前一句可能是真实病程,后一句是对过去陈述的纠错,不能都通过覆盖 bodyStatus 字段处理。
我准备给身体事实和诊断分析记录来源、业务时间与修订身份:诊断应该能知道自己基于哪一版输入,处方应该能知道自己基于哪次被接受的计划,训练反馈则应能够关联确实发生过的训练。
文件也不能跳过来源判断。一张报告照片被上传、OCR 解析完成、某个数值被核验,以及医生是否真正写下明确诊断,是不同步骤。早期的 Assessment 和媒体分析可以帮助生成观察候选,但不应因此再拥有一套能与 BodyState 冲突的身体真值。
接下来最大的设计问题已经不是增加字段,而是把“事实、病名、处方、执行、安全限制、文件证据和对话运行”真正拆成各自有权限的领域。
V2.0.0
决定把 V2 的领域重构放在“唯一权威与跨域引用”上,而不是围绕每个 UI 窗口重新建表。
核心取舍是:每用户各只有一份持续 BodyState、一份综合 Diagnosis、一份持续 Conversation;TrainingPlan 可以有多份,UserAsset / EquipmentAsset 也各自管理独立身份。SafetyState 由 BodyState 持有;Person 是这些领域数据的组合视图,不是第二份身体档案。
classDiagram
direction TB
class User
class BodyState {
+currentRevision
}
class BodyFact {
+source
+temporal
}
class BodyObservation {
+method
+source
}
class SymptomEpisode {
+derivedState
}
class SafetyState {
+restrictions
}
class Diagnosis {
+currentRevision
}
class ConditionEntry {
+medicalName
+assessment
+disposition
}
class TrainingPlan {
+currentPlanRevision
}
class TrainingSessionLog {
+actualActions
}
class Conversation {
+agentRuns
+retentionCursor
}
class UserAsset {
+assetId
+facets
}
class EquipmentAsset {
+availability
}
class PersonView {
+Records
+Habits
+Equipment
+Media
+Reports
}
User "1" *-- "1" BodyState
User "1" *-- "1" Diagnosis
User "1" *-- "1" Conversation
User "1" *-- "*" TrainingPlan
User "1" *-- "*" UserAsset
User "1" *-- "*" EquipmentAsset
BodyState "1" *-- "*" BodyFact
BodyState "1" *-- "*" BodyObservation
BodyState "1" *-- "*" SymptomEpisode
BodyState "1" *-- "1" SafetyState
Diagnosis "1" *-- "*" ConditionEntry
ConditionEntry "*" --> "*" BodyFact : evidence_ref
ConditionEntry "*" --> "*" BodyObservation : evidence_ref
TrainingPlan "1" --> "*" TrainingSessionLog : execution
TrainingPlan "*" --> "*" ConditionEntry : condition_ref
PersonView ..> BodyState : reads
PersonView ..> UserAsset : reads
PersonView ..> EquipmentAsset : reads
图中的关联是领域权威与引用关系,不是数据库外键的完整 ER 图。各领域的选择如下。
BodyState:记录身体事实,而非疾病结论
这里决定把信息分成几种具有不同语义的对象,而不是使用统一的 health_record:
- BodyFact 保存用户明确描述的症状、健康史、活动、生活习惯和训练后反馈。
asserted / superseded / withdrawn代表陈述记录的有效性,不代表疼痛仍持续、已缓解或医学上已经治愈。 - BodyObservation 保存带方法、来源和时间的测量与观察。用户说自己体重多少可以按“自报”保存,但不能自动提升成临床测量。
- BodyHypothesis 只保存用户本人明确提出的猜测;Agent 提出的病因假设留在 Diagnosis 的分析审计中。
- SymptomEpisode 用稳定 ID 和来源关联组织同一段症状经历。
ongoing / ended / unknown必须由有效的时间证据推导;“目前不痛”不一定说明上一段症状已经正式结束。 - DerivedConcernGroup 只为页面组织相关事实、经历与观察,不另建会复制身体事实的 BodyConcern 聚合。
最重要的选择是分清更正旧记录与真实身体变化。更正需要保留被更正记录和来源;新一次发作可以形成新的 Episode。需要跨多段经历合并、拆分或更正关键边界时,必须授权到准确的目标和内容。
明确的本人事实经结构化提取及 Go 领域校验后可以直接入库,不用句句确认;歧义和关键冲突才通过 Ask User 澄清。AI 提取成功不等于已经写入成功,必须有领域提交回执。
classDiagram
direction TB
class BodyState {
+userId
+currentRevision
}
class BodyFact {
+assertionStatus
+temporal
+source
}
class BodyObservation {
+method
+source
+observedAt
}
class BodyHypothesis {
+userAssertion
}
class SymptomEpisode {
+episodeId
+derivedState
}
class SafetyState {
+restrictions
}
class BodyStateRevision {
+changeSet
+sourceId
}
class DerivedConcernGroup {
+readOnlyProjection
}
BodyState "1" *-- "*" BodyFact
BodyState "1" *-- "*" BodyObservation
BodyState "1" *-- "*" BodyHypothesis
BodyState "1" *-- "*" SymptomEpisode
BodyState "1" *-- "1" SafetyState
BodyState "1" *-- "*" BodyStateRevision
SymptomEpisode "*" --> "*" BodyFact : source_links
DerivedConcernGroup ..> BodyFact : groups
DerivedConcernGroup ..> SymptomEpisode : groups
Diagnosis:一份综合诊断,多条独立医学病情
不准备把每轮 AI 输出做成一个新的正式 Diagnosis,也不继续把 Assessment 当作第二个诊断实体。
Diagnosis 每用户一份,ConditionEntry 是其中的一条具名医学疾病或综合征。 “右肩疼痛”属于 BodyState 的症状,不应被迫写成病因明确的 ConditionEntry。病名允许规范化,但实质不同的医学概念需要不同 conditionId。
每条病情要拆开四种维度:
- 医学概念和名称:这究竟指哪一种病情,而非仅靠部位或症状名定位。
- AI 评估:
suspected / likely只是证据支持程度,可缺省,不等于临床确诊。 - 来源与证据:用户猜测、用户转述临床意见、正式临床文件和 Agent 推理必须区分;supporting / counter / context 是不同的证据作用。
- 用户采纳状态:
adopted / inactive只表示是否仍属于当前采纳集合。停用原因单独记录,不能把“停用”误读为治愈或排除。
正式修订采用 ConditionAnalysisRun → ConditionProposal → ConditionProposalDecision → DiagnosisRevision。一次 Run 可以提出多个新增、更新、停用或关系建议;用户逐项确认,但这些确认先成为可恢复的 Decision,不能答应一个就立刻更改正式诊断。
当本次 Run 的决定已具备足够证据和准确授权时,Go 将全部有效更改作为一个补丁一次原子提交,最多产生一份整体 DiagnosisRevision。新证据只要求受影响提案重审;无新证据不能持续重复被明确拒绝的同类建议。若出现真正需要终结正式分析的 urgent 风险,该 Run 保留审计,零诊断提交。
flowchart TD
START[用户请求正式分析] --> RUN[ConditionAnalysisRun]
EVID[BodyState 有版本证据] --> RUN
RUN --> PROPOSAL[生成具名 ConditionProposal]
PROPOSAL --> DECIDE{用户逐项决定}
DECIDE -->|接受或拒绝| AUDIT[记录 ProposalDecision]
DECIDE -->|补充信息| CHECK[更新证据并重核受影响提案]
CHECK --> PROPOSAL
AUDIT --> VALIDATE{全部有效决定可提交?}
VALIDATE -->|否| PENDING[继续等待或恢复]
VALIDATE -->|是| PATCH[一次原子 Diagnosis Patch]
PATCH --> REV[一份 DiagnosisRevision]
RUN --> SAFETY{urgent 安全事件?}
SAFETY -->|是| BLOCK[终结 Run 且零提交]
TrainingPlan / Trainer:处方和真实执行分开
决定把原来的 Treatment / Training 统合思路进一步划成 TrainingPlan(处方) 和 Trainer(执行) 两个明确职责。一个用户可以同时拥有多份计划,各自保持目标、有效内容、生命周期和不可变修订历史。
TrainingPlan 由 PlanSessionTemplate 组织不同课,例如 A/B 课;每课包含动作条目、组数、次数或持续时间、休息、侧别和强度。默认按训练频率表达要求,必要时才增加具体日历排期;没有预排某一天,不应自动制造一次“漏练”。
三个身份不能混用:exerciseId 是动作概念,planExerciseId 是计划中的动作条目,sessionLogId 是真实训练实例。相同动作出现在不同计划不代表同一训练记录。
TrainingSessionLog 只记录真正执行过的动作。用户明确完成、跳过或更正才生成有效记录;点击下一项、播放示范或关闭窗口都不能自动记完成。执行日志引用训练开始时的计划修订、课模板与时区快照,保证以后修改处方也能看出当时练了什么。
首次创建训练计划和修改现有计划都先产生 Draft / PlanProposal。提案有准确的目标、内容指纹、依据与底版,用户接受且校验通过后才成为有效计划或新修订;Agent 建议不能直接改写已接受内容。训练过程中用户明确反馈的身体变化仍写入 BodyState,不由 Trainer 存第二份症状事实。
classDiagram
direction TB
class TrainingPlan {
+planId
+status
+currentRevisionId
}
class TrainingPlanRevision {
+revisionId
+contentSnapshot
}
class PlanSessionTemplate {
+templateId
+stages
}
class PlanExercise {
+planExerciseId
+exerciseId
+dose
}
class PlanProposal {
+proposalId
+contentFingerprint
+userDecision
}
class TrainingSessionLog {
+sessionLogId
+planRevisionId
+trainingDate
}
class SessionAction {
+actualResult
}
class ConditionEntry
class BodyFact
class SafetyState
TrainingPlan "1" *-- "*" TrainingPlanRevision
TrainingPlanRevision "1" *-- "*" PlanSessionTemplate
PlanSessionTemplate "1" *-- "*" PlanExercise
TrainingPlan "1" --> "*" TrainingSessionLog : executed_by
TrainingSessionLog "1" *-- "*" SessionAction
PlanProposal ..> TrainingPlanRevision : accepted_patch
TrainingPlan "*" --> "*" ConditionEntry : references
TrainingPlan "*" --> "*" BodyFact : references
SafetyState ..> TrainingSessionLog : execution_gate
SafetyState:限制执行,不偷偷改处方
Safety 不再用一个全局 isSafe,也不将“因为风险暂停训练”直接映射成 TrainingPlan 的生命周期。
BodyState 持有 SafetyState,SafetyState 持有多条 SafetyRestriction。 每条限制有来源、原因、适用范围和解除依据。产品执行等级分为:
- advisory:警告,但不自动阻断匹配动作。
- restricted:阻断命中的计划或动作;不必把所有训练都关掉。
- urgent:阻断所有训练,并按安全策略优先处理必要的紧急提醒。
匹配可以针对全部训练、某计划或稳定动作概念 exerciseId。多条限制重叠时逐条保留、取最严格的有效门禁。TrainerGate 是实时计算的 allow/warn/block 视图,不再另存一个能被独立篡改的安全状态。
解除条件不能仅依赖“已经过了几天”:
on_expiry:只允许有明确、经认可的期限的规则自动到期。user_reverification:用户对指定风险完成有记录的复核。professional_clearance:需要准确、有来源的专业放行,未经准入的 OCR 片段不能充当医学放行依据。
用户撤回了一条身体陈述,只能触发受影响限制的复查,不能自动解除。Trainer 在开始和继续受影响动作时重新检查门禁;restricted 暂停对应执行,urgent 按安全终止处理,但已完成组次保留,剩余组次不伪造完成或跳过。
这里设计的是产品安全权限模型,不是已经得到临床验证的风险分诊或运动禁忌算法;医疗阈值和专业审查仍须单独完成。
flowchart TD
BS[BodyState] --> SS[SafetyState]
SS --> SR[SafetyRestriction 集合]
SR --> SCOPE{作用范围匹配?}
SCOPE -->|否| ALLOW[不受此条限制]
SCOPE -->|是| MERGE[合并所有有效限制]
MERGE --> LEVEL{最高有效风险}
LEVEL -->|advisory| WARN[Trainer: warn]
LEVEL -->|restricted| HOLD[Trainer: safety_hold]
LEVEL -->|urgent| STOP[Trainer: safety_stopped]
STOP --> DX[需要时终止 Diagnosis Run: safety_blocked]
SR --> LIFT[按 liftPolicy 审核单条解除]
LIFT --> SS
Person / Chat:持续对话与组合视图
用户不需要在 Chat 里管理一堆新建会话。决定每用户保留一份长期 Conversation,每轮消息各有自己的 AgentRun;同时最多一个普通活动 Run,多端竞争由后端串行控制。
需要用户澄清时,Ask User 替换普通输入区域,答案绑定准确的 interactionId / runId / toolCallId。关闭窗口或断网不是默认同意或拒绝,恢复应回到原来的待答交互,而不是新建一份不相干的会话。
Conversation 长期存在,原始 Message 不必永久存在。用 processedThroughSeq、contextStartSeq、prunedThroughSeq 分开表示语义完成、模型使用窗口和物理清理边界;只有必要的长期事实及最低限度的原话来源被所属领域确认保存,且运行恢复依赖清理完成后,才能推进删除游标。
Person 则是组合视图,不是新的健康聚合。 基础 Profile 只存可选姓名、头像、出生日期与偏好,不复制体重、病史和习惯。Records / Habits 读取 BodyState;Equipment 读取设备资产;Media / Reports 读取同一组上传文件。UI 可以把这些入口放在一个窗口里,领域写入仍去找各自 Owner。
flowchart TD
U[User] --> CONV[唯一 Conversation]
CONV --> MSG[近期 Message]
CONV --> RUN[串行 AgentRun]
RUN --> ASK[可恢复 Ask User]
RUN --> CMD[领域操作意图]
CMD --> GO{Go Domain Command}
GO --> BODY[BodyState]
GO --> DX[Diagnosis]
GO --> PLAN[TrainingPlan / Trainer]
GO --> ASSET[UserAsset / EquipmentAsset]
BODY --> PERSON[Person 组合视图]
ASSET --> PERSON
DX --> UI[Diagnosis 窗口]
PLAN --> TRAINUI[Training 窗口]
CONV --> CHATUI[Chat 窗口]
UserAsset / Equipment:文件、证据和器材不是一回事
同一张医疗报告图片可能在 Media 和 Reports 两处显示,不应该因此保存两个实体。决定用 UserAsset 的唯一 assetId 与私有原件表示文件,media / report 只作为展示分类。
对于报告解析,四层概念必须分开:
- UserAsset:原件存在且是否可访问。
- ExtractionRun:一次 OCR / 解析运行及其具体模型/规则版本。
- EvidenceCandidate + Admission:某段文字或表格数值的位置、来源与准入资格。
- BodyObservation / Diagnosis:经各自领域判断后,是否引用这些来源作为观察或正式诊断证据。
上传成功、OCR 成功、证据被准入、用户采纳诊断,绝对不是同一件事。重新解析新建 Run,不覆盖之前使用过的证据版本。
EquipmentAsset 另行记录“用户拥有、借用或可使用哪些器材”。“能用阻力带”属于器材可用性,“每周用三次”才是有时间语义的 BodyFact。器材变化可以提出训练计划修订建议,不能静默修改已接受的处方。
删除也有两个不同操作:移除文件原件可以在清楚标记原件不可用的前提下保留合法的历史观察;真正擦除关联健康数据则要停止新的来源使用、清理授权范围内的派生证据和运行副本,并复核受影响的诊断与安全依赖。擦除不是用户陈述撤回,更不是症状治愈。
classDiagram
direction TB
class UserAsset {
+assetId
+facets
+state
+privateStorageRef
}
class ExtractionRun {
+runId
+mechanismVersion
+processingState
}
class EvidenceCandidate {
+candidateId
+sourceLocator
+admissibility
}
class BodyObservation {
+observationId
+evidenceRefId
}
class EquipmentAsset {
+equipmentId
+accessKind
+availability
}
class TrainingPlan
class Diagnosis
UserAsset "1" *-- "*" ExtractionRun
ExtractionRun "1" *-- "*" EvidenceCandidate
EvidenceCandidate ..> BodyObservation : admitted_source
BodyObservation ..> Diagnosis : supporting_evidence
EquipmentAsset "*" --> "*" TrainingPlan : availability_context
跨域提交:AI 只能提出操作,权威领域负责真正保存
同一轮聊天可能要求保存身体事实、发起诊断分析和提出训练建议,但不应伪造一个可以把所有领域一次提交成功的万能事务。
Go 根据 userId、来源、准确目标、幂等键、授权和预期修订逐一验证所属领域的命令;Python Agent 负责结构化提取、推理与工具调用;前端负责展示和用户决定。只有领域返回真正的 DomainCommitReceipt(或同等持久化结果)才能宣称“已保存”。
单域操作必须原子化;跨域操作允许明确报告“BodyState 已保存,TrainingPlan 提案失败”,并通过可重试、幂等的事件协调后续动作。BodyState 变化可以产生 Diagnosis 复核提示或计划影响建议,却不能悄悄重写 ConditionEntry、解除 SafetyRestriction 或修改被接受的 TrainingPlan。
这次重构的核心不是多建了多少数据表,而是终于让陈述、证据、医学分析、用户采纳、训练处方、执行反馈与安全限制各自有清楚的主人。这些属于目标业务契约;哪些数据库、Go/Python 接口、前端界面已经迁移完成,还需分别按实际代码和验收结果判断。