跳到主要内容

会议议程与行动清单

收藏0

判断是否需要开会,整理带时长和目标的议程,把会议笔记转换为负责人、截止日期明确的行动项。

让 AI 安装

github最新版本 0.1.1更新于 2026-10-21下载 2 次2 个版本

scripts/action_item_extractor.py

9.8 KB
1#!/usr/bin/env python3
2"""action_item_extractor.py — Turn raw meeting notes into an owned, dated action checklist.
3
4The `meetings` skill's third discipline: a meeting that ends without owned, dated actions was
5theater. This script parses raw notes (from --input FILE or stdin) and extracts action items via
6deterministic patterns:
7
8 - "- [ ] ..." / "* [ ] ..." markdown checkboxes
9 - "ACTION: ..." / "TODO: ..." explicit prefixes (case-insensitive)
10 - "@name will ..." / "@name to ..." mention-owned commitments
11 - "Name will <verb> ... by <date>" prose commitments (capitalized name, pronouns excluded)
12
13For every item it captures the OWNER (an @mention leading the item, or the leading
14"Name will/to" name) and the DUE DATE ("by/due/before <weekday|today|tomorrow|EOD|EOW|
15YYYY-MM-DD|D/M|Month D>") when present, then flags:
16
17 ORPHAN — no owner captured. Unowned actions die; assign before posting.
18 NO-DUE — owner but no date. Undated actions drift; date them now.
19
20Output: a markdown checklist grouped by owner (orphans last, loudly) + summary counts, or --json.
21
22NO LLM CALLS. Pure regex + grouping. Stdlib only. Nothing is sent anywhere.
23
24Usage:
25 python action_item_extractor.py --input notes.md
26 cat notes.md | python action_item_extractor.py
27 python action_item_extractor.py --sample --json
28"""
29
30import argparse
31import json
32import re
33import sys
34from typing import Any, Dict, List, Optional, Tuple
35
36CHECKBOX_RE = re.compile(r"^\s*[-*]\s*\[\s*\]\s*(?P<text>.+)$")
37PREFIX_RE = re.compile(r"^\s*(?:ACTION|TODO)\s*:\s*(?P<text>.+)$", re.IGNORECASE)
38MENTION_OWNED_RE = re.compile(r"^\s*@(?P<owner>[A-Za-z][\w.\-]*)\s+(?:will|to)\s+(?P<text>.+)$")
39NAME_WILL_RE = re.compile(r"^\s*(?P<owner>[A-Z][a-zA-Z]+)\s+(?:will|to)\s+(?P<text>.+)$")
40MENTION_HEAD_RE = re.compile(r"^\s*@(?P<owner>[A-Za-z][\w.\-]*)\s*[:,—-]?\s*(?P<text>.*)$")
41INNER_NAME_WILL_RE = re.compile(r"^(?P<owner>[A-Z][a-zA-Z]+)\s+(?:will|to)\s+(?P<text>.+)$")
42
43_DATE_TOKEN = (
44 r"(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday|today|tomorrow|eod|eow"
45 r"|\d{4}-\d{2}-\d{2}"
46 r"|\d{1,2}/\d{1,2}(?:/\d{2,4})?"
47 r"|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2})"
48)
49DUE_RE = re.compile(r"\b(?:by|due|before)\s+(?P<due>" + _DATE_TOKEN + r")\b", re.IGNORECASE)
50
51PRONOUNS = {"We", "I", "It", "They", "This", "That", "You", "He", "She",
52 "Everyone", "Someone", "Anybody", "Nobody", "Team", "The"}
53
54# Sentence-initial capitalized words that are never a person committing to an action.
55NON_OWNER_WORDS = PRONOUNS | {
56 "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday",
57 "January", "February", "March", "April", "May", "June", "July", "August",
58 "September", "October", "November", "December",
59 "Today", "Tomorrow", "Tonight", "Next", "Last", "There",
60 "Everything", "Nothing", "Something", "Everybody",
61}
62
63# "Name will <stative>" is a prediction or status, not a commitment
64# ("Friday will be a half day", "Design will need another pass").
65STATIVE_CONTINUATIONS = {"be", "need", "probably", "likely", "not", "also",
66 "still", "never", "just", "only"}
67
68
69def _is_commitment(text: str, owner: str) -> bool:
70 """A 'Name will/to ...' line is a commitment only if Name can be a person
71 and what follows reads as an action, not a stative prediction."""
72 if owner in NON_OWNER_WORDS:
73 return False
74 first = text.strip().split(None, 1)[0].lower() if text.strip() else ""
75 return first not in STATIVE_CONTINUATIONS
76
77EPILOG = """\
78exit codes:
79 0 success — checklist emitted (ORPHAN / NO-DUE items are flagged in the output, not fatal)
80 2 no input — neither --input, --sample, nor piped stdin was provided (or file unreadable)
81
82--help and --sample exit 0.
83"""
84
85
86def _extract_owner_and_text(text: str, owner: Optional[str]) -> Tuple[Optional[str], str]:
87 """Refine owner from an already-captured action text.
88
89 Only a mention that LEADS the item confers ownership ("@sam: book the room",
90 "@sam to book the room"). A mid-text mention is the task's object, not its
91 owner ("follow up with @sam") — those stay ORPHAN for a human to assign.
92 """
93 m = MENTION_OWNED_RE.match(text.strip())
94 if m: # "@owner will/to rest" at the head — strip the owner phrase from the text
95 return m.group("owner"), m.group("text")
96 m = MENTION_HEAD_RE.match(text.strip())
97 if m:
98 return m.group("owner"), (m.group("text") or text)
99 m = INNER_NAME_WILL_RE.match(text.strip())
100 if m and _is_commitment(m.group("text"), m.group("owner")):
101 return m.group("owner"), m.group("text")
102 return owner, text
103
104
105def extract(notes: str) -> List[Dict[str, Any]]:
106 items: List[Dict[str, Any]] = []
107 for lineno, raw in enumerate(notes.splitlines(), 1):
108 line = raw.rstrip()
109 if not line.strip():
110 continue
111 owner: Optional[str] = None
112 text: Optional[str] = None
113
114 m = CHECKBOX_RE.match(line)
115 if m:
116 text = m.group("text").strip()
117 else:
118 m = PREFIX_RE.match(line)
119 if m:
120 text = m.group("text").strip()
121 else:
122 m = MENTION_OWNED_RE.match(line)
123 if m:
124 owner, text = m.group("owner"), m.group("text").strip()
125 else:
126 m = NAME_WILL_RE.match(line)
127 if m and _is_commitment(m.group("text"), m.group("owner")):
128 owner, text = m.group("owner"), m.group("text").strip()
129
130 if text is None:
131 continue
132
133 # Refine only when no owner was captured at the head of the line — an
134 # already-owned commitment may legitimately name other people in its text
135 # ("@maria will ask @sam to review") without transferring ownership.
136 if owner is None:
137 owner, text = _extract_owner_and_text(text, owner)
138 due_m = DUE_RE.search(line)
139 due = due_m.group("due") if due_m else None
140 text = text.rstrip(".").strip()
141
142 flags: List[str] = []
143 if not owner:
144 flags.append("ORPHAN")
145 if not due:
146 flags.append("NO-DUE")
147 items.append({"line": lineno, "text": text, "owner": owner, "due": due, "flags": flags})
148 return items
149
150
151def summarize(items: List[Dict[str, Any]]) -> Dict[str, int]:
152 return {
153 "total": len(items),
154 "owned": sum(1 for i in items if i["owner"]),
155 "orphans": sum(1 for i in items if "ORPHAN" in i["flags"]),
156 "no_due": sum(1 for i in items if "NO-DUE" in i["flags"]),
157 }
158
159
160def render_markdown(items: List[Dict[str, Any]]) -> str:
161 out: List[str] = ["## Action Items", ""]
162 if not items:
163 out.append("_No action items detected. If decisions were made, they left no owners — "
164 "that is worth fixing in the room next time._")
165 return "\n".join(out)
166
167 owners = sorted({i["owner"] for i in items if i["owner"]}, key=str.lower)
168 for owner in owners:
169 out.append(f"### {owner}")
170 for i in items:
171 if i["owner"] == owner:
172 due = f" — due {i['due']}" if i["due"] else " **[NO-DUE — date it now]**"
173 out.append(f"- [ ] {i['text']}{due}")
174 out.append("")
175
176 orphans = [i for i in items if not i["owner"]]
177 if orphans:
178 out.append("### (unassigned) — ORPHANS, assign before posting")
179 for i in orphans:
180 due = f" — due {i['due']}" if i["due"] else ""
181 out.append(f"- [ ] {i['text']}{due} **[ORPHAN]**")
182 out.append("")
183
184 s = summarize(items)
185 out.append(f"**Summary:** {s['total']} actions · {s['owned']} owned · "
186 f"{s['orphans']} ORPHAN · {s['no_due']} NO-DUE")
187 if s["orphans"]:
188 out.append("")
189 out.append("_Every action item has an owner and a date — or it is not an action item. "
190 "Assign the orphans while the room still remembers agreeing to them._")
191 return "\n".join(out)
192
193
194SAMPLE_NOTES = """\
195Notes — Q3 planning sync
196
197Maria will send the pricing one-pager by Friday.
198We agreed the roadmap shape looks fine overall.
199- [ ] update the launch checklist
200ACTION: @sam to book the security review by 2026-07-24
201TODO: draft the customer announcement email
202Alex will confirm vendor pricing.
203It will probably rain during the offsite.
204"""
205
206
207def main(argv: List[str]) -> int:
208 p = argparse.ArgumentParser(
209 description="Extract owned, dated action items from raw meeting notes (flags ORPHAN / NO-DUE).",
210 formatter_class=argparse.RawDescriptionHelpFormatter,
211 epilog=EPILOG,
212 )
213 p.add_argument("--input", metavar="FILE", help="Notes file to read (otherwise stdin is used)")
214 p.add_argument("--sample", action="store_true", help="Run on the embedded example notes")
215 p.add_argument("--json", action="store_true", help="Emit machine-readable JSON instead of markdown")
216 args = p.parse_args(argv)
217
218 if args.sample:
219 notes = SAMPLE_NOTES
220 elif args.input:
221 try:
222 with open(args.input, "r", encoding="utf-8") as f:
223 notes = f.read()
224 except OSError as e:
225 print(f"error: cannot read {args.input}: {e}", file=sys.stderr)
226 return 2
227 elif not sys.stdin.isatty():
228 notes = sys.stdin.read()
229 else:
230 p.print_help()
231 print("\nerror: provide --input FILE, pipe notes on stdin, or use --sample", file=sys.stderr)
232 return 2
233
234 items = extract(notes)
235 if args.json:
236 print(json.dumps({"items": items, "summary": summarize(items)}, indent=2))
237 else:
238 print(render_markdown(items))
239 return 0
240
241
242if __name__ == "__main__":
243 sys.exit(main(sys.argv[1:]))

文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。