写日程这件事,单独看并不耗时:打开日历应用,点一下“新建事件”,输入标题、时间、地点,保存,十秒钟就结束了。真正让人烦躁的是它打断心流的次数。正在写代码或者调试一个诡异 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.txt:
icalendar>=5.0 requests>=2.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_URL="http://localhost:11434/v1" export LLM_API_KEY="ollama" export LLM_MODEL="qwen2.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"^(?P<date>\d{4}-\d{2}-\d{2})\s+" r"(?P<start>\d{1,2}:\d{2})\s+" r"(?P<summary>.+)$" )这个正则故意写得很严苛,目的是避免模棱两可的输入被错误地当成了规则事件。比如你输入“周五下午三点开会”,正则不会匹配,会继续走 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"^(?P<date>\d{4}-\d{2}-\d{2})\s+" r"(?P<start>\d{1,2}:\d{2})\s+" r"(?P<summary>.+)$" ) 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(tzinfo=TZ) 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( now=now.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": f"Bearer {api_key}"}, json=payload, timeout=60, ) 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(tzinfo=TZ) 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(minutes=ev["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", default="calendar.ics", help="输出的 .ics 文件路径") parser.add_argument("--remind-minutes", type=int, default=10, help="提前提醒分钟数,0 表示不提醒") parser.add_argument("--dry-run", action="store_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__":