让大模型只负责理解人话:从一句话到.ics日历文件
写日程这件事单独看并不耗时打开日历应用点一下“新建事件”输入标题、时间、地点保存十秒钟就结束了。真正让人烦躁的是它打断心流的次数。正在写代码或者调试一个诡异 Bug 的时候脑子里突然冒出一句“明天下午三点和产品对需求”如果你不去处理这件事大概率会忘如果立刻停下处理代价又不止十秒钟。你需要在“继续手头的事”和“把一件事可靠地记下来”之间切换一次而频繁切换注意力才是手动管理日程的真实成本。我解决这个问题的方式是给自己写一款日历 AI 助手把一句话丢给它它自动解析成结构化日程再生成可以被 Outlook、Google Calendar、Apple 日历识别的.ics日历文件。开发过程中我得到的最重要结论是不要让大模型直接负责生成日历文件要让它只负责“理解人话”日历文件的正确性、格式约定和导入规则全部交给代码。这个边界想清楚之后项目从“玩具”变成了“每天都能用”的工具。这篇文章会把完整的实现思路、代码、运行验证和绕坑经验写出来。读完你可以得到三样东西一条从自然语言到.ics文件的完整工程链路、一个可以直接跑起来的命令行版本日历 AI 助手、以及一套处理时区、重复导入和提醒问题的判断标准。它不是那种只会“智能生成”概念的科普文而是能让你下一个周末就把它跑在自己电脑上的落地教程。1. 日历 AI 助手到底解决了什么问题1.1 手动敲日程的真正成本传统日历应用的交互模型是“让人去适配表单”。你要先在脑海里把一个模糊念头翻译成精确字段今天还是明天、几点开始、持续多久、要不要提醒、在哪个日历里。这个翻译动作看起来是免费的但它占据了工作记忆。我做了个小实验连续一周记录自己创建日程的操作发现绝大多数新日程都符合一种非常固定的句式“明天下午 3 点和产品过需求评审”。这种句子里已经包含了标题、时间和地点信息只是日历应用听不懂必须人肉拆开再逐项填入。日历 AI 助手要解决的不是“提醒功能不够强”而是把自然语言转成结构化数据这层翻译工作自动化。从技术角度看这个需求可以拆成四层自然语言理解层、事件结构化层、iCalendar 文件生成层、日历导入层。市面上很多所谓的 AI 日程工具只解决了第一层后面三层不是简单而是“繁琐但必须正确”这恰好是普通代码更适合干的活。一个稳定的日历 AI 助手应该让大模型做它擅长的事情——理解模糊表达让代码做它擅长的事情——生成格式正确、边界清晰的文件。1.2 这个工具适合谁不适合谁先给一个明确边界避免读者做完之后觉得“没用”。这个方案最适合三类人经常在电脑前处理日程但不愿意把所有数据交给某一款商业 AI 助理的个人开发者对本地模型、OpenAI 兼容接口有基本了解想把 LLM 接进真实工作流的工程师对.ics/ iCalendar 格式好奇想理解日历生态底层规则的人。它不太适合的也有三类需要多人协作排期、抢占会议室资源、直接读写公司 Exchange / CalDAV 服务器的场景。原因写在后面这类场景涉及权限管理、审计和冲突检测不是一个本地生成.ics文件的小工具能覆盖的。真要做生产级日历 Agent需要的是有服务端支撑的完整方案而不是个人命令行工具。换个角度说我的项目定位是“个人日程数据整理器”不是“企业日历替代品”。它把用户从表单交互里解脱出来同时保住数据的本地可控性。定位清楚之后后面所有技术选型都顺了。2. 核心设计判断让模型理解人让代码处理日历2.1 为什么不能把整个日历文件交给大模型生成我最早踩的坑是想让大模型一次性输出一个可以导入 Outlook 的.ics全文。表面看这个思路很直接毕竟.ics就是文本文件ChatGPT 之类的能力也够强。但实际测试下来问题集中在三处第一模型会“合理”地编造字段。让大模型生成日历文件时它会为了格式完整而编造DTSTAMP、UID、SEQUENCE甚至可能编出看起来合法的重复规则。日历文件最怕的不是报错而是看起来能导入、导入之后时间却错乱。第二时区很难通过提示词约束。模型经常会输出一个没有时区标识的本地时间或者把Asia/Shanghai和08:00混用一旦导入到不同时区的日历里所有会议都偏移。第三你无法在生成前做校验。所以我的架构变成了这样自然语言文本 ↓ [规则解析兜底] 或 [LLM 结构化抽取] ← 模型/规则只负责转 JSON ↓ 统一事件结构标题/开始时间/时长/地点 ↓ [Python 代码生成 iCalendar 事件] ↓ .ics 文件 → Outlook / Apple 日历 / Google Calendar模型的任务在第二步就结束了。代码拿到的是结构化 JSON由代码负责把 JSON 变成带UID、带DTSTAMP、带正确时区的日历事件。模型负责把不确定的自然语言变成确定的数据代码负责把确定的数据变成不能出错的日历格式。2.2 规则解析为什么仍然值得保留看到这里你可能会有疑问既然已经接了大模型为什么还要保留一套正则解析兜底我的理由很朴素一个工具不能在高依赖组件失效时就完全瘫痪。如果本地模型服务没启动、API Key 没配置或者网络异常这个助手至少还能处理“2025-07-25 09:30 做周报”这种显式输入。另外规则解析的返回值是一个天然稳定的测试基准当你调整 LLM 提示词时可以用同样的输入对比结构化结果是否合理。因此完整解析顺序是先用正则匹配显式日期格式匹配不到再走 LLM如果 LLM 调用失败程序抛出明确错误而不是静默生成错误日程。这个降级策略让工具在开发和日常两个阶段都更可用。2.3 各类方案对比方案优点缺点适用场景全交给大模型生成 .ics 文本演示效果好看起来自动化程度高字段易编造、时区不稳、无法在生成前校验不推荐用于真实日程大模型抽取 JSON 代码生成 .ics解析能力强日历格式稳定需要维护两套模块推荐路线纯正则解析零依赖、可离线、稳定只支持少数固定句式适合兜底和测试基准直接调用商业日历 API功能完整、支持协作需要处理 OAuth、权限、审计企业级场景这张表可以帮你在动手前做一个理智选型不要因为“AI 很火”就让 AI 去承担它不擅长的精确性工作。3. 环境准备与技术选型3.1 运行环境与依赖本文示范代码使用 Python 3.10 及以上版本因为用到了标准库zoneinfo来管理本地时区。操作系统不限Windows、macOS、Linux 都可以跑下文命令以 macOS / Linux 的 shell 为例Windows 用户可以改成 PowerShell 里的$env:语法。核心依赖只有两个icalendar负责生成和解析 iCalendar 格式文件requests用来请求大模型接口。日期时间处理尽量使用标准库datetime和zoneinfo少引一层就少一个版本坑。创建requirements.txticalendar5.0 requests2.31安装pip install -r requirements.txt强调一点icalendar库的版本不要盲目追求最新以能正常from icalendar import Calendar, Event, Alarm为准。版本请以实际项目为准本文演示的核心思路与库主版本关联不大。3.2 大模型接口选型本地模型或任何 OpenAI 兼容服务为了让同一个工具同时支持“完全本地运行”和“调用云服务”我选择用 OpenAI 兼容的/v1/chat/completions接口作为标准协议。这样对接范围非常广本地可以通过 Ollama 起一个兼容接口其他提供 OpenAI 兼容 API 的服务同样可以接进来。环境变量设计如下export LLM_BASE_URLhttp://localhost:11434/v1 export LLM_API_KEYollama export LLM_MODELqwen2.5:7b如果你本机装了 Ollama 并且已经拉取了qwen2.5:7b上面这套配置直接可用。如果没装本地模型也可以把LLM_BASE_URL指向任意支持兼容协议的服务并把LLM_MODEL换成对应的模型名。这里特意做成环境变量是出于安全考虑不要把 Key 写死在代码里后面接 Git 仓库时才不会泄露。没有配置任何环境变量时程序依然可以启动只是只能用显式日期格式。这是“渐进增强”的思路先跑通再接模型。4. 从一句话到 .ics 文件的实现思路4.1 输入与输出我期望的日常用法是这样的python calendar_assistant.py 明天下午3点开发组周会约1小时地点A座会议室程序输出一个calendar.ics文件。双击这个文件系统日历会弹出“导入事件”的确认框确认后事件就落进日历。整个过程不需要打开日历界面新建表单也不需要手动拆解这句话里的时间、时长和地点。为了让输出可控我把最终事件结构固定为四个核心字段summary日程标题start_time带时区的开始时间duration_minutes持续分钟数location、description可选的补充信息。4.2 规则解析兜底规则解析只支持一种显式格式2025-07-25 09:30 做周报正则如下它把日期、开始时间和标题拆出来FALLBACK_PATTERN re.compile( r^(?Pdate\d{4}-\d{2}-\d{2})\s r(?Pstart\d{1,2}:\d{2})\s r(?Psummary.)$ )这个正则故意写得很严苛目的是避免模棱两可的输入被错误地当成了规则事件。比如你输入“周五下午三点开会”正则不会匹配会继续走 LLM 解析。4.3 LLM 结构化提示词提示词是整个自然语言解析质量的关键。我建议在系统提示词中注入“当前本地时间”因为“明天”“周五”“下周一”这类表达依赖一个参考时间点。模型如果没有参考时间就会用一个随意的“当前日期”这是很多日程 Agent 时间推断错误的第一来源。提示词的要点有三条要求模型只输出 JSON不要输出解释文字指定start_time必须是 ISO 8601 格式且使用本地时区要求模型对“今天/明天/周几”做未来时间推断如果某个时间表述已经过去就顺延到下一个匹配日期。4.4 生成 iCalendar 事件的字段注意点写日历文件时有一个容易忽略的细节一个规范的.ics事件必须要有UID和DTSTAMP。UID是日历应用的去重标识DTSTAMP表示事件创建时间。如果没有这两个字段多数日历应用也能导入但当你在同一个日历文件里反复导入、删除、再导入时可能会出现重复事件。另外事件的开始时间最好使用带时区的datetime对象。不要手动拼字符串否则你迟早会在某个时区问题上浪费一个下午。调用icalendar库时直接传入datetime对象即可由库负责序列化。5. 日历 AI 助手完整代码实现下面是完整代码保存为calendar_assistant.py。它实现了我上面说的完整流程规则解析优先、LLM 解析兜底、事件结构统一、写入.ics文件。#!/usr/bin/env python3 # -*- coding: utf-8 -*- 日历 AI 助手 用法 python calendar_assistant.py 明天下午3点开发组周会约1小时地点A座会议室 python calendar_assistant.py 2025-07-25 09:30 做周报 可选环境变量 LLM_BASE_URL OpenAI 兼容接口地址例如 http://localhost:11434/v1 LLM_API_KEY 接口密钥本地 Ollama 可填 ollama LLM_MODEL 模型名例如 qwen2.5:7b TZ 时区默认 Asia/Shanghai import os import re import json import argparse from datetime import datetime, timedelta from zoneinfo import ZoneInfo import requests from icalendar import Calendar, Event, Alarm TZ ZoneInfo(os.environ.get(TZ, Asia/Shanghai)) FALLBACK_PATTERN re.compile( r^(?Pdate\d{4}-\d{2}-\d{2})\s r(?Pstart\d{1,2}:\d{2})\s r(?Psummary.)$ ) SYSTEM_PROMPT_TEMPLATE 你是一个日程解析助手。用户会给你一句口语化的日程安排你需要把它转换成 JSON。 当前本地时间{now} 当前星期{weekday} 只输出 JSON不要输出任何解释文字不要使用 Markdown 代码块。JSON 字段 - summary: 字符串日程标题必填 - location: 字符串地点没有则填空字符串 - start_time: 字符串开始时间ISO 8601 格式 YYYY-MM-DDTHH:MM:SS必填 - duration_minutes: 整数持续分钟数默认 60 - description: 字符串补充说明没有则填空字符串 要求 1. 根据当前时间推断“今天”“明天”“周几”“下周一”等表达。 2. 如果用户说的是过去的时间选择未来最近的一个相同时间点。 3. summary、location、description 都使用用户输入的原文不要编造用户没提到的信息。 def parse_fallback(text: str): 规则解析支持 YYYY-MM-DD HH:MM 标题 这一种显式格式。 m FALLBACK_PATTERN.match(text.strip()) if not m: return None start datetime.fromisoformat(f{m.group(date)}T{m.group(start)}) start start.replace(tzinfoTZ) return { summary: m.group(summary).strip(), location: , start_time: start, duration_minutes: 60, description: , } def _extract_json(text: str) - dict: 从模型返回文本中提取 JSON 对象容忍代码块和前后缀。 match re.search(r\{.*\}, text, re.S) if not match: raise ValueError(f模型未输出 JSON原始内容{text}) return json.loads(match.group(0)) def parse_with_llm(text: str): 调用 OpenAI 兼容接口让模型抽取结构化日程。 base_url os.environ.get(LLM_BASE_URL, ).rstrip(/) api_key os.environ.get(LLM_API_KEY, ) model os.environ.get(LLM_MODEL, ) if not base_url or not api_key or not model: raise RuntimeError(未配置 LLM_BASE_URL / LLM_API_KEY / LLM_MODEL) now datetime.now(TZ) system_prompt SYSTEM_PROMPT_TEMPLATE.format( nownow.strftime(%Y-%m-%d %H:%M:%S), weekday一二三四五六日[now.weekday()], ) payload { model: model, messages: [ {role: system, content: system_prompt}, {role: user, content: text}, ], temperature: 0, } resp requests.post( f{base_url}/chat/completions, headers{Authorization: fBearer {api_key}}, jsonpayload, timeout60, ) resp.raise_for_status() content resp.json()[choices][0][message][content] item _extract_json(content) start_time item.get(start_time) if not start_time: raise ValueError(模型返回缺少 start_time) start_dt datetime.fromisoformat(start_time) if start_dt.tzinfo is None: start_dt start_dt.replace(tzinfoTZ) start_dt start_dt.astimezone(TZ) return { summary: str(item.get(summary, )).strip(), location: str(item.get(location, )).strip(), start_time: start_dt, duration_minutes: int(item.get(duration_minutes, 60)), description: str(item.get(description, )).strip(), } def parse_text(text: str): 统一解析入口先规则后 LLM。 event parse_fallback(text) if event: print([解析] 使用规则解析显式时间格式) return event print([解析] 规则未命中尝试调用大模型接口) event parse_with_llm(text) print([解析] 大模型返回结果成功) return event def events_to_ics(events, ics_path: str, remind_minutes: int 10) - int: 把事件列表写入 .ics 文件支持追加到已有日历文件。 if os.path.exists(ics_path): with open(ics_path, rb) as f: cal Calendar.from_ical(f.read()) else: cal Calendar() cal.add(prodid, -//Calendar AI Assistant//Calendar Assistant//CN) cal.add(version, 2.0) for ev in events: event Event() # UID 和 DTSTAMP 是日历去重的重要依据不能省略 uid_suffix datetime.now(TZ).strftime(%Y%m%d%H%M%S%f) event.add(uid, f{uid_suffix}-calendar-assistant) event.add(dtstamp, datetime.now(TZ)) event.add(summary, ev[summary]) event.add(dtstart, ev[start_time]) event.add( dtend, ev[start_time] timedelta(minutesev[duration_minutes]), ) if ev.get(location): event.add(location, ev[location]) if ev.get(description): event.add(description, ev[description]) if remind_minutes 0: alarm Alarm() alarm.add(action, DISPLAY) alarm.add(description, f提醒{ev[summary]}) alarm.add(trigger, timedelta(minutes-remind_minutes)) event.add_component(alarm) cal.add_component(event) with open(ics_path, wb) as f: f.write(cal.to_ical()) added len(events) total len([comp for comp in cal.walk() if comp.name VEVENT]) return total - added, total def main(): parser argparse.ArgumentParser(description日历 AI 助手) parser.add_argument(text, help日程描述例如明天下午3点开发组周会) parser.add_argument(--out, defaultcalendar.ics, help输出的 .ics 文件路径) parser.add_argument(--remind-minutes, typeint, default10, help提前提醒分钟数0 表示不提醒) parser.add_argument(--dry-run, actionstore_true, help只打印解析结果不写文件) args parser.parse_args() try: event parse_text(args.text) except Exception as exc: print(f[错误] 日程解析失败{exc}) print(排查建议) print( 1. 如果走 LLM 解析先确认 Ollama 等本地服务已经启动); print( 2. 检查 LLM_BASE_URL、LLM_API_KEY、LLM_MODEL 环境变量); print( 3. 如果规则解析失败输入必须是 YYYY-MM-DD HH:MM 标题 格式); return 1 if event[start_time] datetime.now(TZ): print([警告] 事件开始时间早于当前时间请人工确认后再导入) print(\n解析结果) print(f 标题{event[summary]}) print(f 开始{event[start_time]}) print(f 时长{event[duration_minutes]} 分钟) print(f 地点{event[location] or 未填写}) if event.get(description): print(f 备注{event[description]}) if args.dry_run: print(\n[dry-run] 不写入文件) return 0 added, total events_to_ics([event], args.out, args.remind_minutes) print(f\n已写入 {args.out}新增 {added} 个事件文件中现有 {total} 个事件) return 0 if __name__ __main__: