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 | |
| 4 | The `meetings` skill's third discipline: a meeting that ends without owned, dated actions was |
| 5 | theater. This script parses raw notes (from --input FILE or stdin) and extracts action items via |
| 6 | deterministic 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 | |
| 13 | For 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| |
| 15 | YYYY-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 | |
| 20 | Output: a markdown checklist grouped by owner (orphans last, loudly) + summary counts, or --json. |
| 21 | |
| 22 | NO LLM CALLS. Pure regex + grouping. Stdlib only. Nothing is sent anywhere. |
| 23 | |
| 24 | Usage: |
| 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 | |
| 30 | import argparse |
| 31 | import json |
| 32 | import re |
| 33 | import sys |
| 34 | from typing import Any, Dict, List, Optional, Tuple |
| 35 | |
| 36 | CHECKBOX_RE = re.compile(r"^\s*[-*]\s*\[\s*\]\s*(?P<text>.+)$") |
| 37 | PREFIX_RE = re.compile(r"^\s*(?:ACTION|TODO)\s*:\s*(?P<text>.+)$", re.IGNORECASE) |
| 38 | MENTION_OWNED_RE = re.compile(r"^\s*@(?P<owner>[A-Za-z][\w.\-]*)\s+(?:will|to)\s+(?P<text>.+)$") |
| 39 | NAME_WILL_RE = re.compile(r"^\s*(?P<owner>[A-Z][a-zA-Z]+)\s+(?:will|to)\s+(?P<text>.+)$") |
| 40 | MENTION_HEAD_RE = re.compile(r"^\s*@(?P<owner>[A-Za-z][\w.\-]*)\s*[:,—-]?\s*(?P<text>.*)$") |
| 41 | INNER_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 | ) |
| 49 | DUE_RE = re.compile(r"\b(?:by|due|before)\s+(?P<due>" + _DATE_TOKEN + r")\b", re.IGNORECASE) |
| 50 | |
| 51 | PRONOUNS = {"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. |
| 55 | NON_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"). |
| 65 | STATIVE_CONTINUATIONS = {"be", "need", "probably", "likely", "not", "also", |
| 66 | "still", "never", "just", "only"} |
| 67 | |
| 68 | |
| 69 | def _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 | |
| 77 | EPILOG = """\ |
| 78 | exit 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 | |
| 86 | def _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 | |
| 105 | def 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 | |
| 151 | def 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 | |
| 160 | def 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 | |
| 194 | SAMPLE_NOTES = """\ |
| 195 | Notes — Q3 planning sync |
| 196 | |
| 197 | Maria will send the pricing one-pager by Friday. |
| 198 | We agreed the roadmap shape looks fine overall. |
| 199 | - [ ] update the launch checklist |
| 200 | ACTION: @sam to book the security review by 2026-07-24 |
| 201 | TODO: draft the customer announcement email |
| 202 | Alex will confirm vendor pricing. |
| 203 | It will probably rain during the offsite. |
| 204 | """ |
| 205 | |
| 206 | |
| 207 | def 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 | |
| 242 | if __name__ == "__main__": |
| 243 | sys.exit(main(sys.argv[1:])) |
文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。

