02-NaturalTimeParser-把明天早上八点变成时间戳
上一篇我们把提醒表t_reminder的字段拆完了,里面有个绕不过去的坎:
用户说的是"明天早上八点",数据库存的是
2026-09-15 08:00:00。
中间这段翻译工作,就是NaturalTimeParser干的活。它只有 133 行代码,却是整个提醒功能里最容易被低估、也最容易出 bug的一块——因为用户在时间表达上,创造力是无限的。
这一篇我们把它的实现拆开讲清楚:支持哪些说法、正则怎么写、时段怎么换算、失败怎么办,最后给一份能直接抄走的测试用例表。
一、为什么不用现成的库,非要自己写一个
做自然语言时间解析,第一反应是"找个库不就行了"。Java 生态里确实有几类现成方案,但在这个项目里我最后选择了手写,理由有三条:
| 方案 | 能力 | 落地成本 | 结论 |
|---|---|---|---|
通用 NLP 时间库(如各种*TimeParser) | 强,能处理"下周三下午茶时间" | 依赖重、中文表达覆盖参差、规则黑盒 | 不适合嵌入式陪伴场景的确定性需求 |
| 交给大模型直接输出时间 | 极强 | 大模型可能算错日期、输出格式不稳、每次都要多花 Token | 作主路径不稳,但可以做兜底 |
| 手写规则解析 | 只覆盖常见表达 | 可控、零依赖、结果可预测 | ✅ 本项目选它 |
关键在于场景的时间表达是收敛的。老人不会说"下个季度的第三周周二",他们会说:
- “明天八点”
- “每天九点”
- “每周一上午十点”
覆盖这几类,就能拿下 95% 的真实请求。剩下的交给人(提示用户换个说法)比交给黑盒算法更稳。
二、支持的表达全清单
先看它到底认哪些说法,这是最重要的一张表:
| 类别 | 示例输入 | 产出 |
|---|---|---|
| 标准日期时间 | 2026-09-15 08:00 | 绝对时间 |
| 标准日期时间(斜杠) | 2026/09/15 08:00 | 绝对时间 |
| 标准日期时间(带秒) | 2026-09-15 08:00:00 | 绝对时间 |
| ISO 风格 | 2026-09-15T08:00 | 绝对时间 |
| 中文日期 | 2026年9月15日 08:00 | 绝对时间 |
| 仅日期 | 2026-09-15 | 绝对时间(当天 00:00) |
| 仅时间 | 08:00 | 今天 08:00 |
| 相对日 + 点 | 明天8点、后天21点、大后天7点 | 对应日期 + 时间 |
| 今天 + 点 | 今天下午3点 | 今天 15:00 |
| 每天重复 | 每天9点、每天晚上九点 | cron0 0 21 * * * |
| 每周重复 | 每周一9点、每周日上午10点 | cron0 0 10 * * 0 |
| 时段词 | 上午 / 下午 / 晚上 / 中午 / 凌晨 | 参与 12→24 小时换算 |
| 解析失败 | 下个月底、一会儿 | 返回null |
注意最后一行的态度:不认识的表达直接返回null,不猜。这是这个组件最重要的设计决策——对于"提醒吃药"这种场景,宁可让用户重说一遍,也不能猜错时间。
三、三个正则撑起全部解析
整个解析器只用三个正则(项目源码util/NaturalTimeParser.java):
// 时间点:可选的时段词 + 数字 + "点"或":" + 可选分钟privatestaticfinalPatternHOUR_MINUTE=Pattern.compile("(?:(上午|下午|晚上|中午|凌晨)?)\\s*(\\d{1,2})(?:点|:)(?:(\\d{1,2})分?)?");// 日期词:相对日 + 重复日privatestaticfinalPatternDAY_WORD=Pattern.compile("(今天|明天|后天|大后天|每天|每周[一二三四五六日天])");// 每周XprivatestaticfinalPatternWEEKDAY=Pattern.compile("每周([一二三四五六日天])");逐个解读一下设计意图:
HOUR_MINUTE是核心。它把"下午3点"拆成三组:下午/3/ 空;把"9点30分"拆成:空 /9/30;把"08:00"也吃进来(因为(?:点|:)支持冒号)。用了\\s*容忍"下午 3 点"这种带空格的写法。
DAY_WORD是个"扫词器",它只负责回答’哪天’,具体几点交给HOUR_MINUTE。两者独立匹配、最后组合,这就是为什么"明天上午"(没有时间点)不会被误判——下面的流程会说清楚。
WEEKDAY用来在DAY_WORD之前先把"每周X"这种重复语义捞出来,因为"每周一9点"和"明天9点"的处理路径完全不同(一个出 cron,一个出绝对时间)。
四、主干流程:三个分支,优先级不能乱
解析主干长这样(项目源码,节选了核心逻辑):
publicParseResultparse(Stringtext){if(text==null||text.isBlank())returnnull;Stringinput=text.trim().toLowerCase(Locale.ROOT);ParseResultstandard=parseStandard(input);// ① 标准格式优先if(standard!=null)returnstandard;MatcherweekdayMatcher=WEEKDAY.matcher(input);// ② 每周X + 点 -> cronif(weekdayMatcher.find()){intdow=weekdayToCron(weekdayMatcher.group(1));Matcherhm=HOUR_MINUTE.matcher(input);if(hm.find()){inthour=normalizeHour(Integer.parseInt(hm.group(2)),hm.group(1));intminute=hm.group(3)==null?0:Integer.parseInt(hm.group(3));returnnewParseResult(null,String.format("0 %d %d * * %d",minute,hour,dow));}}MatcherdayMatcher=DAY_WORD.matcher(input);// ③ 今天/明天/每天 + 点StringdayWord=dayMatcher.find()?dayMatcher.group(1):null;Matcherhm=HOUR_MINUTE.matcher(input);if(hm.find()){inthour=normalizeHour(Integer.parseInt(hm.group(2)),hm.group(1));intminute=hm.group(3)==null?0:Integer.parseInt(hm.group(3));LocalTimetime=LocalTime.of(hour,minute);if("每天".equals(dayWord))returnnewParseResult(null,String.format("0 %d %d * * *",minute,hour));LocalDatedate=LocalDate.now();if(dayWord!=null){date=switch(dayWord){case"明天"->date.plusDays(1);case"后天"->date.plusDays(2);case"大后天"->date.plusDays(3);default->date;};}returnnewParseResult(LocalDateTime.of(date,time),null);}returnnull;}这段代码的优先级顺序是精华,值得单独强调:
- 标准格式最先。因为
2026-09-15 08:00这种输入是无歧义的,先吃掉它能避免被后续的模糊规则污染。 - 重复语义排在相对日之前。“每周一9点"里同时含"每周"和"9点”,如果先走相对日分支,
DAY_WORD会先匹配到"每周一"(它就在候选词里),然后落进switch的default分支——结果变成"今天的 9 点"。这是个隐蔽的坑,靠分支顺序避开了。 - 没有时间点就不解析。这是最容易忽略的一条:如果输入是"明天"两个字(
dayWord命中但HOUR_MINUTE没命中),第三个分支的if (hm.find())不成立,直接走到最后return null。不会返回"明天 00:00"这种看似合理的错答案。
顺带说个小疑问你可能会想:DAY_WORD正则里已经把"每周X"包含进去了,为什么还要单独用WEEKDAY先匹配一次?答案就是上面第 2 点——先处理更具体的语义,让DAY_WORD安心当"哪天"的翻译官。
五、时段换算:12 小时制到 24 小时制
中文时间表达里,最大的歧义源是"点"到底指上午还是下午。解析器用normalizeHour处理:
| 时段词 | 规则 | 示例 |
|---|---|---|
| 下午 / 晚上 | 小于 12 时 +12 | 下午3点 → 15;晚上9点 → 21 |
| 中午 | 小于 11 时 +12 | 中午12点 → 12;中午10点 → 22(注意这个边界) |
| 凌晨 | 等于 12 时归 0 | 凌晨12点 → 0;凌晨3点 → 3 |
| 上午 / 无 | 原样 | 上午9点 → 9;9点 → 9 |
这张表里有几个地方值得停下想一秒:
- "中午10点"会被算成 22 点——这明显是个不自然的表达,用户几乎不会说,但规则上确实是这个结果。规则解析的代价就在这:边界靠约定,不靠理解。可以接受的取舍。
- 没写时段词时一律按 24 小时制原样。"9点"就是早上 9 点。这里我没做"如果 9 点已过就顺延到明天"的智能推断,因为那会让同一个输入在不同时刻解析出不同结果——提醒功能最忌讳不确定性。
六、weekdayToCron:中文星期到 cron 的映射
映射规则很简单,但有个"反直觉"的点:
| 中文 | cron 值 | 说明 |
|---|---|---|
| 每周一 | 1 | — |
| 每周二 | 2 | — |
| 每周三 | 3 | — |
| 每周四 | 4 | — |
| 每周五 | 5 | — |
| 每周六 | 6 | — |
| 每周日 / 每周天 | 0 | cron 里周日是 0(部分实现也接受 7) |
这个 0/1 起点差异是 cron 领域的经典坑:星期是 0 或 1 起(周日/周一),而月份和日期是 1 起(112、131),秒分时则是 0 起。混着记容易翻车,正确姿势是现场查表,别背。
七、ParseResult:为什么是"二选一"
解析结果的类型定义很干净:
publicrecordParseResult(LocalDateTimedateTime,Stringcron){}一次性的时间写进dateTime,重复规则写进cron,另一个永远是null。这种"二选一"的互斥设计,比起"两个字段都可能填"要安全得多——下游只需要判断哪个非空,不需要处理"两个都有值该听谁"的歧义。
用record而不是普通类也是个好选择:不可变、自带equals/hashCode/toString、代码短。解析结果这种值对象,天生就该是不可变的。
它的消费方就是提醒工具链:ReminderTool.createReminder收到remindTimeStr后交给ReminderService.createFromAgent,后者调解析器拿到ParseResult,再把dateTime写进remindTime、把cron写进cron字段。
看到这里的读者应该会心一笑了:cron就是前面说的那个"存了没人读"的字段。解析器辛辛苦苦把"每天9点"翻译成0 0 9 * * *,落库了,然后调度器只看remindTime——所以"每天"这个语义,目前是丢在路上的。
八、失败路径:null之后发生了什么
解析器返回null时,链路是这样的:
| 环节 | 行为 |
|---|---|
NaturalTimeParser.parse | 返回null |
ReminderService.createFromAgent | 无法得到时间,创建失败并抛异常 |
ReminderTool.createReminder | 它是唯一带 try-catch 的工具 → 返回字符串"提醒创建失败:xxx" |
| 大模型 | 收到工具失败信息,生成安抚式回复:“这个时间我还没学会理解,您能说得再具体些吗?比如’明天早上八点’” |
| 用户 | 换一种说法重试 |
这条链路是我最喜欢的设计之一:失败被翻译成了"对话",而不是"报错弹窗"。陪伴机器人的交互原则在这里体现得很清楚——工具失败不应该让用户看到堆栈,而应该变成一句人话,并且带上引导(给出例子)。
顺便点评一下这个分工:工具方法给模型返回事实(成功/失败 + 原因),模型负责把它翻译成人话。这套"两次翻译"的结构,和上一篇讲的工具返回ID=1024是同一个思路。
九、可以直接抄走的测试用例表
手写解析器最怕改一处崩三处。下面是这个组件值得固化成单测的用例,建议直接抄进项目再逐步加:
| # | 输入 | 期望输出 | 覆盖点 |
|---|---|---|---|
| 1 | 2026-09-15 08:00 | 绝对时间 08:00 | 标准格式 |
| 2 | 2026年9月15日 08:00 | 绝对时间 08:00 | 中文日期 |
| 3 | 08:00 | 今天 08:00 | 仅时间默认今天 |
| 4 | 明天8点 | 明天 08:00 | 相对日 |
| 5 | 后天21点 | 后天 21:00 | 相对日 + 24 时制 |
| 6 | 大后天7点 | 大后天 07:00 | 相对日边界 |
| 7 | 下午3点 | 今天 15:00 | 时段换算 |
| 8 | 晚上9点 | 今天 21:00 | 时段换算 |
| 9 | 凌晨12点 | 今天 00:00 | 12 → 0 边界 |
| 10 | 每天9点 | cron0 0 9 * * * | 每天重复 |
| 11 | 每周一9点 | cron0 0 9 * * 1 | 每周重复 |
| 12 | 每周日上午10点 | cron0 0 10 * * 0 | 周日=0 边界 |
| 13 | 明天 | null | 无时间点不瞎猜 |
| 14 | 下个月底 | null | 未支持表达 |
| 15 | ""/null | null | 空值防御 |
第 10、11 条现在只验证"解析对了",还应该补一条集成测试验证"到点会不会真的重复触发"——这条目前必然失败(cron未被消费),正好可以作为改造任务的验收标准。
十、还能往上加的三件事
- 相对偏移表达:
半小时后、两小时后、十分钟后。这类表达只需要匹配"数字 + 单位 + 后",转成now.plusXxx()即可,实现成本很低,但覆盖的是临时性提醒的高频说法。 - 跨天判断与提示:用户晚上 11 点说"今天 8 点提醒我",逻辑上已经过去了。合理做法不是顺延,而是明确反问一次:“您说的是今晚 8 点吗?已经过了,要改成明天吗?”——把歧义交还给人。
- 模糊表达降级为大模型兜底:
下个月底、这个周末这类规则难覆盖的表达,可以交给大模型输出结构化时间,再用解析器做二次校验(校验不过就丢弃)。注意顺序很重要:先用确定性规则,再用不确定性模型兜底,而不是反过来。
十一、小结
NaturalTimeParser用三个正则加 133 行代码,解决了陪伴机器人里最"土"也最关键的一个问题:把人说的话变成机器能扫的时间。
它的三个设计原则值得所有做类似功能的人记住:
- 只认明确表达,不认识就返回
null——宁可多问一句,不能猜错一次; - 优先级决定正确性——标准格式 > 重复语义 > 相对日,顺序调换就出 bug;
- 输出结构互斥——一次性和重复用二选一的字段表达,下游不用处理歧义。
至于解析出来的cron目前还没被调度器消费这件事,就是我们下一篇要动手术的地方。