slack-sieve/scripts/auto-cherry-pick.py
ilia 4b034d19f6
Some checks failed
CI / skip-ci-check (push) Successful in 4s
CI / secret-scan (push) Failing after 2s
CI / python-ci (push) Successful in 25s
Add triage tests, prepare-export, and UI fixes for Levkin migration.
Cherry-pick heuristics, manifest merge, global file search, scrollbar styling, and pytest/Playwright coverage unblock review and filtered export for mmetl.
2026-06-03 15:42:48 -04:00

440 lines
13 KiB
Python

#!/usr/bin/env python3
"""Bulk cherry-pick data-rich messages (files, facts) — not conversation."""
from __future__ import annotations
import json
import re
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
MANIFEST_PATH = REPO_ROOT / "manifest.json"
DEFAULT_EXPORT = Path.home() / "Downloads/Levkin Slack export enriched"
SKIP_SUBTYPES = {
"channel_join",
"channel_leave",
"channel_topic",
"channel_purpose",
"channel_name",
"channel_archive",
"channel_unarchive",
"group_join",
"group_leave",
}
RETURN_CHANNELS = {"29wilson", "520steeles"}
NOISE_TEXT = re.compile(
r"^(ok|okay|thanks|thx|ty|lol|lmao|\+1|done|yes|no|yep|nope|sure|cool|nice|got it|"
r"will do|sounds good|perfect|great|👍|👌)\.?!?$",
re.IGNORECASE,
)
# Concrete facts — not decision/planning chatter that merely mentions a topic word.
DATA_URL = re.compile(r"https?://|www\.", re.IGNORECASE)
DATA_EMAIL = re.compile(r"\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b", re.IGNORECASE)
DATA_PHONE = re.compile(
r"(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b"
)
DATA_MONEY = re.compile(
r"\$\s*[\d,]+(?:\.\d{2})?|\b[\d,]+\s*(?:cad|usd|dollars?)\b", re.IGNORECASE
)
DATA_DATE = re.compile(
r"\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|"
r"(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{1,2}(?:,?\s+\d{4})?)\b",
re.IGNORECASE,
)
DATA_ADDRESS = re.compile(
r"\b\d{1,5}\s+\w+(?:\s+\w+){0,3}\s+"
r"(?:st|street|ave|avenue|rd|road|dr|drive|blvd|way|crt|court|lane|ln|unit|apt|#)\b",
re.IGNORECASE,
)
DATA_ACCOUNT = re.compile(
r"\b(?:account|policy|invoice|receipt|confirmation|booking|reference|"
r"transit|etransfer|sin|passport)\s*#?\s*[:#]?\s*[\w-]{4,}\b",
re.IGNORECASE,
)
CHAT_PATTERNS = re.compile(
r"\b(?:should we|what do you think|let me know|any thoughts|sounds good|"
r"i think we|we could|maybe we|let's\b|can you\b|do you want|"
r"worth it|decided to|going to\b|planning to|wondering if)\b",
re.IGNORECASE,
)
NAME_LIST_TOPIC = re.compile(
r"\b(?:baby\s+names?|girl\s+names?|boy\s+names?|name\s+list|names?\s+we\s+like|"
r"favorite\s+names?|shortlist|hebrew\s+name|yiddish\s+name|middle\s+name)\b",
re.IGNORECASE,
)
INFORMATIVE_FILE = re.compile(
r"invoice|receipt|lease|statement|bill|contract|insurance|tax|form|report|"
r"prescription|diagnosis|lab|result|scan|document|agreement|deed|mortgage|"
r"hoa|permit|inspection|warranty|quote|estimate|spreadsheet|\.pdf|\.doc|\.xls",
re.IGNORECASE,
)
CASUAL_IMAGE = re.compile(
r"^image\.|screenshot|slack-img|photo|img_\d|snap|slack-image|mvimg",
re.IGNORECASE,
)
PROPERTY_CHANNELS = {
"284richmond",
"45ritson",
"122harmony",
"6keefer",
"153niagara",
"banking",
"realestate",
"airbnb",
"york",
"viewings",
}
KIDS_CHANNELS = {
"zoey",
"izik",
"parenting",
"moishe",
"baby",
"after-school-activities",
}
FAMILY_CHANNELS = {"levkin", "medical", "purchase"}
AUTO_CHANNELS = PROPERTY_CHANNELS | KIDS_CHANNELS | FAMILY_CHANNELS
IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "heic"}
def draft_group_key(name: str) -> str:
n = name.lower().strip()
n = re.sub(r"^draft[\s_\-]+", "", n)
n = re.sub(r"[\s_\-]*(final|v\d+|rev\d*|\(\d+\)|copy)", "", n)
n = re.sub(r"\.[a-z0-9]{1,5}$", "", n)
n = re.sub(r"\s+", " ", n).strip()
return n or name.lower()
def is_image(file: dict) -> bool:
mt = (file.get("mimetype") or "").lower()
if mt.startswith("image/"):
return True
name = (file.get("name") or "").lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
return ext in IMAGE_EXTS
def is_doc(file: dict) -> bool:
name = (file.get("name") or "").lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
return ext in {"pdf", "doc", "docx", "xls", "xlsx", "csv", "ppt", "pptx"}
def is_draft_file(file: dict) -> bool:
return "draft" in (file.get("name") or "").lower()
_DATA_PATTERNS = (
DATA_URL,
DATA_EMAIL,
DATA_PHONE,
DATA_MONEY,
DATA_DATE,
DATA_ADDRESS,
DATA_ACCOUNT,
)
def data_signal_count(text: str) -> int:
return sum(1 for p in _DATA_PATTERNS if p.search(text))
def has_data_signals(text: str) -> bool:
return data_signal_count(text) > 0
def text_is_factual(text: str) -> bool:
"""Require hard facts, not a lone link in chat."""
if not text or not text.strip():
return False
if looks_conversational(text):
return False
n = data_signal_count(text)
if n >= 2:
return True
if DATA_PHONE.search(text) or DATA_MONEY.search(text) or DATA_EMAIL.search(text):
return True
if DATA_ADDRESS.search(text) and DATA_DATE.search(text):
return True
return False
def looks_conversational(text: str) -> bool:
if not text or NOISE_TEXT.match(text.strip()):
return True
if CHAT_PATTERNS.search(text):
return True
if text.count("?") >= 2 and len(text) < 500:
return True
# Short back-and-forth without hard facts.
if len(text) < 80 and not has_data_signals(text):
return True
return False
def is_name_list_message(text: str) -> bool:
if not text or not text.strip():
return False
if NAME_LIST_TOPIC.search(text):
return True
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
if len(lines) >= 4:
name_lines = sum(
1
for ln in lines
if re.match(r"^[\s•\-\d.]*[A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,})?\s*$", ln)
)
if name_lines >= 3:
return True
return False
def informative_filename(name: str) -> bool:
return bool(INFORMATIVE_FILE.search(name or ""))
def informative_image(file: dict, text: str) -> bool:
name = file.get("name") or ""
if CASUAL_IMAGE.search(name):
return text_is_factual(text)
if informative_filename(name):
return True
return text_is_factual(text)
def build_latest_draft_winners(export, channel: str) -> tuple[set[str], dict[str, set[str]]]:
groups: dict[str, list[tuple[float, str, str]]] = defaultdict(list)
for msg in export.iter_channel_messages(channel):
for f in msg.get("files") or []:
name = f.get("name") or ""
if not is_draft_file(f):
continue
try:
ts = float(str(msg.get("ts", "0")).split(".")[0])
except (TypeError, ValueError):
ts = 0.0
fid = f.get("id") or name
groups[draft_group_key(name)].append((ts, msg["id"], fid))
msg_ids: set[str] = set()
files_by_msg: dict[str, set[str]] = defaultdict(set)
for items in groups.values():
_ts, msg_id, fid = max(items, key=lambda x: x[0])
msg_ids.add(msg_id)
files_by_msg[msg_id].add(fid)
return msg_ids, files_by_msg
def should_import(
msg: dict,
channel: str,
*,
latest_draft_msgs: set[str],
) -> bool:
subtype = msg.get("subtype")
text = (msg.get("text") or "").strip()
files = msg.get("files") or []
msg_id = msg["id"]
if subtype in SKIP_SUBTYPES and not files:
return False
if msg.get("is_bot") and not files:
return False
# --- Attachments (primary signal) ---
if files:
if msg_id in latest_draft_msgs:
return True
for f in files:
if is_draft_file(f):
continue
if is_doc(f):
return True
if informative_filename(f.get("name") or ""):
return True
if is_image(f) and informative_image(f, text):
return True
return False
# --- Text without files: facts only, not chat ---
if channel in KIDS_CHANNELS and is_name_list_message(text):
return True
return text_is_factual(text)
def msg_selection(
msg: dict,
*,
latest_draft_files: set[str] | None = None,
) -> dict:
text = (msg.get("text") or "").strip()
files: dict[str, bool] = {}
for f in msg.get("files") or []:
fid = f.get("id") or f.get("name")
if not fid:
continue
if is_draft_file(f):
if latest_draft_files and fid in latest_draft_files:
files[fid] = True
continue
if is_doc(f) or informative_filename(f.get("name") or ""):
files[fid] = True
elif is_image(f) and informative_image(f, text):
files[fid] = True
return {
"action": "import",
"checked": True,
"merge_target": "",
"notes": "auto-cherry-pick",
"files": files,
}
def channel_entry_skip(notes: str) -> dict:
return {
"import": False,
"status": "skip",
"reviewed": True,
"import_mode": "all",
"notes": notes,
"mattermost_channel": "",
"mattermost_team": "",
"messages": {},
}
def channel_shell() -> dict:
return {
"import": True,
"status": "partial",
"reviewed": True,
"import_mode": "pick",
"notes": "auto-cherry-pick — data/files only",
"mattermost_channel": "",
"mattermost_team": "",
"messages": {},
}
def reset_all_picks(manifest: dict) -> None:
channels = manifest.setdefault("channels", {})
for name, entry in channels.items():
if not isinstance(entry, dict):
continue
entry["messages"] = {}
if name in RETURN_CHANNELS:
channels[name] = channel_entry_skip(
"Returned — not importing (29wilson / 520steeles)"
)
continue
if entry.get("status") == "skip":
continue
entry["import"] = False
entry["status"] = "undecided"
entry["reviewed"] = False
entry["import_mode"] = "all"
def main() -> int:
dry_run = "--dry-run" in sys.argv
reset_only = "--reset" in sys.argv
export_path = DEFAULT_EXPORT
for arg in sys.argv[1:]:
if not arg.startswith("-") and Path(arg).expanduser().is_dir():
export_path = Path(arg).expanduser().resolve()
sys.path.insert(0, str(REPO_ROOT))
from slack_export import SlackExport
export = SlackExport(export_path)
manifest: dict = {"channels": {}, "users": {}}
if MANIFEST_PATH.is_file():
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
reset_all_picks(manifest)
print("Cleared picks — data/files only mode.")
for name in sorted(RETURN_CHANNELS):
print(f" #{name} → skip")
if reset_only:
if not dry_run:
manifest["updated_at"] = datetime.now(tz=timezone.utc).isoformat()
MANIFEST_PATH.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return 0
channels = manifest.setdefault("channels", {})
stats: dict[str, int] = {}
for ch_name in sorted(AUTO_CHANNELS):
if ch_name in RETURN_CHANNELS:
continue
folder = export.channel_folder(ch_name)
if not folder:
continue
entry = channels.setdefault(ch_name, channel_shell())
entry["messages"] = {}
if entry.get("status") == "import" and entry.get("import_mode", "all") != "pick":
print(f"#{ch_name}: whole-channel import — left unchanged")
continue
entry["import"] = True
entry["status"] = "partial"
entry["reviewed"] = True
entry["import_mode"] = "pick"
latest_draft_msgs, draft_files_by_msg = build_latest_draft_winners(export, ch_name)
messages = entry.setdefault("messages", {})
picked = 0
for msg in export.iter_channel_messages(ch_name):
if not should_import(msg, ch_name, latest_draft_msgs=latest_draft_msgs):
continue
messages[msg["id"]] = msg_selection(
msg, latest_draft_files=draft_files_by_msg.get(msg["id"])
)
picked += 1
stats[ch_name] = picked
print(f"#{ch_name}: {picked} msgs")
total = sum(stats.values())
print(f"\nTotal: {total} messages (data/files only)")
if dry_run:
print("(dry-run — manifest not written)")
return 0
manifest["updated_at"] = datetime.now(tz=timezone.utc).isoformat()
note = manifest.get("global_notes") or ""
stamp = "auto-cherry-pick v3 — data/files only, no conversation"
if stamp not in note:
manifest["global_notes"] = (note + "\n" + stamp).strip()
MANIFEST_PATH.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"Wrote {MANIFEST_PATH}")
return 0
if __name__ == "__main__":
raise SystemExit(main())