ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

AI陪伴机器人提醒域建模-t_reminder字段与五种提醒类型

AI陪伴机器人提醒域建模-t_reminder字段与五种提醒类型 01-提醒域建模-t_reminder字段与五种提醒类型聊陪伴机器人大家第一反应都是能聊天吗“会不会讲故事”。但真把一台机器人放进有老人的家里最先被用起来、也最离不开的功能往往不是聊天而是提醒。道理很简单聊天解决的是寂寞提醒解决的是忘事。而忘事这件事轻则忘喝水忘复诊重则忘吃药。所以在我这个「AI 伙伴AI-Partner」项目里提醒域t_reminder是第一批就被认真设计的数据模型。这一篇我们把这张表掰开揉碎它的每个字段为什么存在、五种提醒类型怎么分、时间为什么需要两个字段、状态为什么也拆成了两个。看完你大概会发现一个设计得好的表本身就是一份需求文档。一、先想清楚陪伴机器人的提醒和手机闹钟有什么不一样如果只是想到点响一声手机闹钟就够了用不着机器人。所以提醒功能的设计起点必须先回答一个问题它凭什么比闹钟好差别有三层维度手机闹钟陪伴机器人提醒触发方式手动设置时间用自然语言说一句明天八点提醒我吃药表达方式滴滴滴用你的称呼、你的语气播报出来“张阿姨该吃降压药啦”上下文无知道你是老人还是孩子、知道你昨天情绪不好、知道你血压偏高失败处理无响过就算完理论上可做未接提醒、家属通知本项目暂未实现第一层是入口的差异第二层是人设的差异第三层才是真正的产品护城河。而这三层最终都要落回同一张表。二、t_reminder的全字段拆解先上表结构。下面是这个实体的真实字段定义项目源码entity/Reminder.java字段Java 类型列定义 / 注解默认值说明idLongId GeneratedValue(IDENTITY)自增主键userIdLongColumn(nullablefalse)必填归属用户t_user.iddeviceIdStringColumn(length64)可空计划播报的设备编码typeStringColumn(length32)TYPE_CUSTOM提醒类型见下节titleStringColumn(nullablefalse,length128)必填提醒标题contentStringColumn(length512)可空提醒正文remindTimeLocalDateTimeColumn(nullablefalse)必填绝对触发时间cronStringColumn(length64)可空重复表达式statusStringColumn(length16)STATUS_PENDING业务状态pushedBoolean—false是否已推送createdAtLocalDateTime生命周期回调—创建时间updatedAtLocalDateTime生命周期回调—更新时间配套的索引-- 建表脚本索引项目源码INDEXidx_reminder_user(user_id),INDEXidx_reminder_time_status(remind_time,status)两个索引分别服务于两类查询按人查提醒列表和按时扫到期提醒。这个分工非常清晰说明设计者一开始就想清楚了这张表会被谁怎么读。顺带说一句t_reminder里没有外键。整个项目的 8 张表都没建外键表间关系全靠user_id/device_id这类字段做逻辑关联。这不是偷懒而是一种取舍——我们后面在数据库设计那篇会专门聊它的代价。三、五种提醒类型medication/schedule/water/birthday/custom类型枚举是这么定的项目源码publicstaticfinalStringTYPE_MEDICATIONmedication;// 吃药publicstaticfinalStringTYPE_SCHEDULEschedule;// 日程publicstaticfinalStringTYPE_WATERwater;// 喝水publicstaticfinalStringTYPE_BIRTHDAYbirthday;// 生日publicstaticfinalStringTYPE_CUSTOMcustom;// 自定义为什么不干脆全用custom加一个自由文本因为类型不只是个标签它决定了后面三件事类型用户会怎么说典型标题建议的默认重复策略风险等级medication“提醒我早上七点吃药”“吃降压药”每天 / 按医嘱周期高漏服有健康风险schedule“周三下午三点去复查”“去医院复查”单次中water“每小时提醒我喝水”“喝点水吧”每天多次低birthday“8 月 12 号是我孙子生日”“孙子生日”每年低但漏了很尴尬custom“十点提醒我关火”用户原话单次视内容而定这张表最右边的风险等级是我在做这个项目时逐渐意识到的东西提醒是有安全边界的。药吃错时间、复诊忘掉可能直接影响健康而喝水提醒漏一次真的没关系。风险高的类型工程上就应该有更强的保障多重触达、送达确认、失败重试而现实是——这三种保障本项目目前一个都没实现。所以这里的第一个诚实结论是类型枚举已经为分级保障留好了位置但分级逻辑还没写。后文会给出补法。四、Agent 是怎么决定该存哪个类型的提醒不是用户在表单里点出来的而是从一句自然语言里长出来的。工具方法的签名长这样项目源码agent/tools/ReminderTool.javaTool(为用户创建一条提醒吃药/日程/喝水/生日/自定义返回创建结果和提醒 ID)publicStringcreateReminder(Stringtitle,Stringcontent,StringremindTimeStr,Stringtype,StringrepeatCron,StringdeviceCode,ToolMemoryIdLonguserId){try{LongreminderIdreminderService.createFromAgent(userId,title,content,remindTimeStr,type,repeatCron,deviceCode);return提醒已创建IDreminderIdtitleremindTimeStr;}catch(Exceptione){return提醒创建失败e.getMessage();}}注意Tool描述里那句吃药/日程/喝水/生日/自定义“——这不是给人看的注释是给大模型看的选项说明。工具描述的措辞直接决定模型传什么type进来。你写提醒类型”它就乱猜你把候选值列出来它命中率立刻上来。另外一个细节值得单独拎出来讲createReminder是六个工具类、十三个Tool方法里唯一带 try-catch 的一个。为什么偏偏是它因为在所有工具里创建提醒是最容易失败又最需要给用户反馈的一个时间解析可能失败“下个月底”、必填字段可能缺、数据库可能抽风。其它工具挂了顶多这一轮对话少记一条情绪提醒创建挂了老人以为机器人记下了其实啥都没存——这是会造成实际后果的。工程经验在这里可以总结成一句异常处理要按失败后果分配而不是按代码美观分配。五、remindTime和cron并存一个必须讲清楚的设计意图remindTime是nullablefalsecron是可空字符串。这个组合的意图是很清楚的remindTime 下一次或唯一一次该响的时间绝对时间扫描器直接比大小cron 如果这条提醒要重复重复规则是什么。听起来合理但实际运行起来的真相是这一点必须如实说ReminderScheduler的到期扫描方法findDue只用了remindTime和pushed两个条件从未读取cron。也就是说用户说每天 21 点提醒我泡脚系统会把cron老老实实存下来然后这条提醒在当天 21 点响一次之后……就没有之后了。重复提醒在当前版本是不生效的。这个 bug 挺典型的它教给我们一个数据建模的道理存下来的字段如果没有任何代码路径读它那它不是设计是占位。字段和消费方必须成对出现否则三个月后接手的人会以为这块功能是好的。正确的重复提醒建模常见有三种做法方案做法优点缺点A. 单一推进字段只留next_trigger_time响过后按cron算下一次并回写表最简单、扫描逻辑不变无法表达跳过某次B. 母版 实例t_reminder存规则t_reminder_instance存每次具体触发支持改期、跳过、送达确认实例表会膨胀需要清理C. 纯 cron 表只存cron由调度框架Quartz 等托管复用成熟调度能力与业务状态耦合较松本项目当前的字段布局其实已经站在方案 A 的门口了——remindTime就是那个next_trigger_time只是缺了响过之后按 cron 回写这最后一步。补法是在dispatchDueReminders里推送成功后判断cron是否非空非空则算出下一次时间回写remindTime并把pushed复位为false。六、status和pushed为什么状态要存两个初看会觉得冗余既然有status为啥还要一个pushed布尔值publicstaticfinalStringSTATUS_PENDINGpending;// 待触发publicstaticfinalStringSTATUS_DONEdone;// 已完成publicstaticfinalStringSTATUS_CANCELLEDcancelled;// 已取消因为这两个字段回答的是两个不同的问题字段回答的问题关注方status这条提醒在业务上还有效吗用户、Agent查待办列表时用pushed这条提醒投递出去了吗调度器扫描到期时用举个具体场景你就明白为什么要分开用户取消了提醒statuscancelled但这条记录在数据库里还是存在的调度器扫描时如果不看status只看remindTime就会给一条已取消的提醒发播报指令——那就尴尬了老人会说我都取消了你还提醒我。反过来如果只有status没有pushed那你没法区分还没到点和到点了但推送失败了。而推送失败恰恰是提醒功能里最需要处理的情况。不过现状要说清楚当前调度扫描的查询条件是status remindTimenow pushedfalse逻辑是对的但推送失败时只打了一行log.error没有失败次数字段、没有下次重试时间、没有死信表。所以pushed现在实际只有否 → 是单向流转缺少失败态的出口。一个更完整的字段设计会是pushed boolean 是否已送达 push_attempts int 尝试次数示意当前未实现 next_retry_at datetime 下次重试时间示意当前未实现 last_error varchar 最后一次失败原因示意当前未实现七、deviceId为空的时候到底在谁的嘴里说话t_reminder.deviceId是可空的。为什么可空因为用户说提醒我吃药的时候心里根本没想让哪台设备说。那到点的时候听谁的现状是这样的调度器会去找该用户状态为在线的设备取到设备编码后把指令发到server/设备编码/cmd。设备在线状态的判断依据是t_device.status STATUS_ONLINE。这里有两个坑都是真实存在的状态位不如时间差可靠。t_device里明明存了lastHeartbeatAt字段但代码里从头到尾没有读过它。设备拔网线不会主动告警我离线了它的状态就会永远停在online。于是机器人会一脸认真地对着空气说话日志上一切正常。正确做法是心跳超时即离线now - lastHeartbeatAt 阈值就判离线。这一条我们在设备域那篇会详细展开。一人多设备时存在在哪个房间说话的问题。如果用户在客厅和卧室各放了一台只靠找一台在线设备是碰运气的。产品上合理的做法是优先选最近一次检测到用户活动的那台其次是用户最后交互的那台。八、索引与查询一次到期扫描的完整 SQL 视角提醒域有两条主要读取路径对应的派生查询方法项目源码repository/ReminderRepository.java方法用途触发的索引findByUserIdAndStatusOrderByRemindTimeAsc(userId, status)用户查看待触发提醒Agent 的listReminders工具也走这里idx_reminder_user(userId)findByStatusAndRemindTimeLessThanEqualAndPushedFalse(status, now)调度器扫描到期提醒期望走idx_reminder_time_status(remindTime, status)第二条查询值得掰扯一下。索引建的是(remind_time, status)而查询条件是WHEREstatus?ANDremind_time?ANDpushedfalseORDERBYremind_time从最左前缀原则看remind_time打头是有意义的——因为查询里对remind_time用的是范围条件正好可以吃到排序。而如果把status放最前(status, remind_time)等值条件先命中、再走范围理论上过滤更精准但会失去remind_time的天然有序性排序就得额外做。这两种建法都对取决于数据分布如果status的取值区分度很低比如 99% 的数据都是pending那status放前面几乎没用(remind_time, status)反而更好。索引顺序不是背口诀是看数据分布。真实项目里可以两条都建、看执行计划再砍。至于pushed没有进索引是因为它区分度太低布尔值单独进索引用处不大。真要让这条查询跑到极致可以做成部分索引/复合索引(status, remind_time, pushed)或者干脆把已推送的记录归档到历史表让热表永远保持小体量——后者对定时扫描类是更彻底的解法。九、从一句话到一条记录全链路字段映射把前面所有字段串起来看一次真实交互用户“明天早上八点提醒我吃降压药。”环节产出大模型判断这是要创建提醒 → 调用ReminderTool.createReminder模型填参title吃降压药、content可为空或补充说明、remindTimeStr明天早上八点、typemedication、repeatCronnull、deviceCodenullNaturalTimeParserremindTimeStr→ 绝对时间如次日的 08:00ReminderService.createFromAgent落库userId由ToolMemoryId注入、remindTime、type、statuspending、pushedfalse返回给模型提醒已创建ID1024吃降压药明天早上八点模型生成回复“好嘞明天早上八点我会提醒您吃降压药记得别空腹”注意最后两行工具返回的是给模型看的事实ID 标题模型再翻译成给用户听的话。这个两次翻译的结构是多用户 Agent 的关键——用户永远不该看到ID1024这种东西而模型需要它来做后续的取消、修改。十、这张表还能立刻改进的五件事最后给一份可以直接照着做的清单也是本项目提醒域目前真实的短板让cron真正生效推送成功后按cron计算下一次remindTime并复位pushed。失败要有出口加push_attempts/next_retry_at/last_error三个字段用退避策略重试超过 N 次落死信表。送达确认提醒播报后要求设备回一条ack超时未确认则升级触达方式重复播报、家属通知。按类型分级保障medication类走强保障多次播报 未确认升级water类走轻保障响一次即可。类型枚举已经备好缺的是分支。归档冷热分离把done且超过 30 天的提醒移到历史表主表只留待触发与近期数据让每分钟一次的扫描始终跑在小表上。十一、小结t_reminder这张表只有 11 个业务字段但它把陪伴机器人提醒功能的骨架都撑起来了类型说什么、时间什么时候说、设备在哪说、状态还说吗、投递说到了吗。也给所有做类似功能的同学提个醒数据表设计完之后一定要回头检查每个字段的读取方在哪。像cron这种存了没人读的字段在 Demo 阶段看不出问题等接了真实用户、被问一句为什么每天提醒只响一次才会浮出水面。早发现早补成本最低。提醒功能只做了一半的严肃性在于它会给人已经记住了的错觉。对老人来说这种错觉比没有提醒更危险。所以宁可少承诺几个功能也要把提醒的送达闭环做扎实。任何健康相关提醒都只是辅助手段用药与治疗请务必遵照医生意见并让家属共同把关。
返回列表