write_facebook_conversations.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """Validate translated Facebook conversations and write them to the workbook."""
  4. from __future__ import annotations
  5. import argparse
  6. import json
  7. import re
  8. import subprocess
  9. import sys
  10. from datetime import date, datetime, timedelta
  11. from pathlib import Path
  12. from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
  13. from openpyxl import load_workbook
  14. from openpyxl.styles import Font, PatternFill
  15. from openpyxl.utils import get_column_letter
  16. SCRIPT_DIR = Path(__file__).resolve().parent
  17. SKILL_ROOT = SCRIPT_DIR.parents[1]
  18. COMMON_DIR = SKILL_ROOT / "scripts" / "common"
  19. if str(COMMON_DIR) not in sys.path:
  20. sys.path.insert(0, str(COMMON_DIR))
  21. from artifact_manager import create_backup_once, new_run_id, project_root, resolve_artifact_path # type: ignore # noqa: E402
  22. from workbook_resolver import resolve_workbook_path # type: ignore # noqa: E402
  23. FACEBOOK_SHEET = "\u5ba2\u6237\u4fe1\u606f\u6c47\u603b\u8868"
  24. CONVERSATION_SHEET = "Facebook对话记录"
  25. CONVERSATION_HEADERS = [
  26. "记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID",
  27. "消息时间", "消息方向", "发件人", "原文语言", "对话原文", "中文翻译", "消息类型",
  28. "是否有效客户回复", "合作意向", "意向判断依据", "下一步建议", "同步时间",
  29. "来源账号/Profile ID", "风险标记",
  30. ]
  31. FACEBOOK_REPLY_STATUS = "Facebook\u5df2\u56de\u590d"
  32. OLD_REPLY_STATUS_PHRASES = [
  33. "\u5df2\u56de\u590d,\u6709\u5408\u4f5c\u610f\u5411", "\u5df2\u56de\u590d,\u5f85\u8ddf\u8fdb", "\u5df2\u56de\u590d,\u5f85\u6f84\u6e05", "\u5df2\u56de\u590d,\u6682\u4e0d\u8003\u8651", "\u5df2\u56de\u590d,\u660e\u786e\u62d2\u7edd",
  34. "\u5df2\u56de\u590d\uff0c\u6709\u5408\u4f5c\u610f\u5411", "\u5df2\u56de\u590d\uff0c\u5f85\u8ddf\u8fdb", "\u5df2\u56de\u590d\uff0c\u5f85\u6f84\u6e05", "\u5df2\u56de\u590d\uff0c\u6682\u4e0d\u8003\u8651", "\u5df2\u56de\u590d\uff0c\u660e\u786e\u62d2\u7edd",
  35. ]
  36. NOTE_START = "\u3010Facebook\u56de\u590d\u8bb0\u5f55\u3011"
  37. NOTE_END = "\u3010/Facebook\u56de\u590d\u8bb0\u5f55\u3011"
  38. QUESTION_MARK_RE = re.compile(r"\?{3,}")
  39. CJK_RE = re.compile(r"[\u3400-\u9fff]")
  40. FACEBOOK_ALIASES = {
  41. "index": ["序号", "编号", "ID"],
  42. "company": ["客户姓名/公司", "公司名称", "公司姓名", "客户名称"],
  43. "link": ["主页/链接", "Facebook主页链接", "Facebook链接", "facebook链接"],
  44. "status": ["建联状态", "建联情况"],
  45. "followup": ["下次跟进", "下次跟进时间"],
  46. "note": ["备注", "说明"],
  47. }
  48. def clean(value: Any) -> str:
  49. if value is None:
  50. return ""
  51. return re.sub(r"\s+", " ", str(value).strip())
  52. def now_text() -> str:
  53. return datetime.now().astimezone().isoformat(timespec="seconds")
  54. def read_json(path: Path) -> Dict[str, Any]:
  55. return json.loads(path.read_text(encoding="utf-8-sig"))
  56. def write_json(path: Path, data: Dict[str, Any]) -> None:
  57. path.parent.mkdir(parents=True, exist_ok=True)
  58. path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
  59. def normalized_link(value: str) -> str:
  60. value = clean(value).casefold().split("?", 1)[0].rstrip("/")
  61. return value.removeprefix("https://").removeprefix("http://").removeprefix("www.")
  62. def list_values(value: Any) -> List[str]:
  63. if isinstance(value, list):
  64. return [clean(item) for item in value if clean(item)]
  65. return [part for part in re.split(r"[;;|]+", clean(value)) if part] if clean(value) else []
  66. def analysis_key(thread: Dict[str, Any]) -> Tuple[str, str, str]:
  67. return (
  68. clean(thread.get("customer_index")),
  69. clean(thread.get("thread_id")),
  70. normalized_link(clean(thread.get("facebook_link"))),
  71. )
  72. def raw_thread_key(thread: Dict[str, Any]) -> Tuple[str, str, str]:
  73. customer = thread.get("customer") or {}
  74. return (
  75. clean(customer.get("index")),
  76. clean(thread.get("thread_id") or customer.get("expected_identity")),
  77. normalized_link(clean(customer.get("facebook_link"))),
  78. )
  79. def fallback_analysis_match(raw_thread: Dict[str, Any], candidates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
  80. raw_key = raw_thread_key(raw_thread)
  81. for candidate in candidates:
  82. candidate_key = analysis_key(candidate)
  83. checks = [left and right and left == right for left, right in zip(raw_key, candidate_key)]
  84. if sum(checks) >= 2:
  85. return candidate
  86. return None
  87. def chinese_or_same(original: str, translated: str) -> str:
  88. original, translated = clean(original), clean(translated)
  89. if translated:
  90. return translated
  91. if not original:
  92. return ""
  93. if CJK_RE.search(original):
  94. return original
  95. raise ValueError(f"Missing Chinese translation for message: {original[:80]}")
  96. def validate_intent(value: str) -> str:
  97. return clean(value)
  98. def parse_date(value: str) -> Optional[date]:
  99. match = re.search(r"(20\d{2})[-/](\d{1,2})[-/](\d{1,2})", clean(value))
  100. if not match:
  101. return None
  102. try:
  103. return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
  104. except ValueError:
  105. return None
  106. def add_business_days(start: date, days: int) -> date:
  107. result = start
  108. while days > 0:
  109. result += timedelta(days=1)
  110. if result.weekday() < 5:
  111. days -= 1
  112. return result
  113. def default_followup(intent: str, latest_reply_at: str, explicit: str = "") -> str:
  114. if clean(explicit):
  115. parsed = parse_date(explicit)
  116. if not parsed:
  117. raise ValueError(f"Invalid next_followup date: {explicit!r}")
  118. return parsed.isoformat()
  119. kind, amount = FOLLOWUP_DAYS[intent]
  120. if kind == "none":
  121. return ""
  122. start = parse_date(latest_reply_at) or date.today()
  123. return (add_business_days(start, amount) if kind == "business" else start + timedelta(days=amount)).isoformat()
  124. def header_map(ws, aliases: Dict[str, List[str]]) -> Dict[str, int]:
  125. raw = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
  126. mapped: Dict[str, int] = {}
  127. for key, names in aliases.items():
  128. for name in names:
  129. if name in raw:
  130. mapped[key] = raw[name]
  131. break
  132. return mapped
  133. def ensure_conversation_sheet(wb, sheet_name: str):
  134. if sheet_name not in wb.sheetnames:
  135. ws = wb.create_sheet(sheet_name)
  136. ws.append(CONVERSATION_HEADERS)
  137. else:
  138. ws = wb[sheet_name]
  139. existing = {clean(cell.value) for cell in ws[1] if clean(cell.value)}
  140. for header in CONVERSATION_HEADERS:
  141. if header not in existing:
  142. ws.cell(row=1, column=ws.max_column + 1).value = header
  143. header_fill = PatternFill(fill_type="solid", fgColor="D9EAF7")
  144. for cell in ws[1]:
  145. cell.font, cell.fill = Font(bold=True), header_fill
  146. headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
  147. widths = [38, 12, 28, 38, 24, 22, 12, 20, 12, 56, 56, 14, 18, 16, 52, 52, 22, 22, 32]
  148. for header, width in zip(CONVERSATION_HEADERS, widths):
  149. ws.column_dimensions[get_column_letter(headers[header])].width = width
  150. ws.freeze_panes = "A2"
  151. ws.auto_filter.ref = ws.dimensions
  152. return ws
  153. def message_analysis_map(thread: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
  154. return {clean(item.get("record_id")): item for item in thread.get("messages", []) if clean(item.get("record_id"))}
  155. def is_real_customer_reply(direction: str, kind: str, original: str, translation: str) -> bool:
  156. if clean(direction) != "\u5ba2\u6237\u56de\u590d":
  157. return False
  158. blocked_types = {
  159. "\u7cfb\u7edf\u6d88\u606f", "\u81ea\u52a8\u56de\u590d", "\u5df2\u8bfb\u63d0\u793a",
  160. "\u70b9\u8d5e", "\u8868\u60c5", "reaction", "read_receipt",
  161. }
  162. if clean(kind).casefold() in {item.casefold() for item in blocked_types}:
  163. return False
  164. # 翻译以“(自动回复)”开头说明分析判定为自动回复,不视为人工有效回复
  165. if clean(translation).startswith("\uff08\u81ea\u52a8\u56de\u590d\uff09"):
  166. return False
  167. # 纯时间戳/加载提示不是有效回复
  168. _orig = clean(original).strip().lower()
  169. _trans = clean(translation).strip().lower()
  170. if _orig in ("loading...",) or "loading..." in _trans:
  171. return False
  172. import re as _re
  173. if _re.match(r"^((mon|tue|wed|thu|fri|sat|sun)\s+)?\d{1,2}:\d{2}(\s*(am|pm))?$", _orig):
  174. return False
  175. # 主页简介(Dealership/Dealer/Showroom 结尾的页面介绍)不是有效回复
  176. if _re.search(r"(automotive dealership|car dealership|car dealer|car rental|showroom|cars)$", _orig):
  177. return False
  178. emoji_only = clean(original or translation) in {"??", "??", "??", "??", "?", "??", "?"}
  179. return not emoji_only
  180. def merge_threads(raw: Dict[str, Any], analysis: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
  181. analysis_threads = analysis.get("threads") or []
  182. exact = {analysis_key(thread): thread for thread in analysis_threads}
  183. output_rows: List[Dict[str, Any]] = []
  184. customer_updates: List[Dict[str, Any]] = []
  185. warnings: List[str] = []
  186. sync_time = now_text()
  187. for raw_thread in raw.get("threads") or []:
  188. if raw_thread.get("status") != "collected":
  189. warnings.append(f"{clean((raw_thread.get('customer') or {}).get('company'))}: {clean(raw_thread.get('status'))}")
  190. continue
  191. messages = raw_thread.get("messages") or []
  192. if not messages:
  193. continue
  194. athread = exact.get(raw_thread_key(raw_thread)) or fallback_analysis_match(raw_thread, analysis_threads) or {}
  195. amap = message_analysis_map(athread) if athread else {}
  196. customer = raw_thread.get("customer") or {}
  197. real_reply_rows: List[Dict[str, Any]] = []
  198. for raw_message in messages:
  199. record_id = clean(raw_message.get("record_id"))
  200. if not record_id:
  201. raise ValueError("Raw message is missing record_id")
  202. analyzed = amap.get(record_id) or {}
  203. direction = clean(raw_message.get("direction"))
  204. kind = clean(raw_message.get("message_type")) or "\u6587\u672c"
  205. original = clean(raw_message.get("original_text"))
  206. translated_text = clean(analyzed.get("chinese_translation"))
  207. if direction == "\u5ba2\u6237\u56de\u590d":
  208. translation = chinese_or_same(original, translated_text)
  209. else:
  210. translation = translated_text or original
  211. original_language = clean(analyzed.get("original_language")) or ("zh" if CJK_RE.search(original) else "")
  212. is_reply = is_real_customer_reply(direction, kind, original, translation)
  213. next_action = clean(analyzed.get("next_action")) if is_reply else ""
  214. if is_reply and not next_action:
  215. next_action = "\u8bf7\u4eba\u5de5\u67e5\u770b\u5ba2\u6237\u56de\u590d\uff0c\u5e76\u5224\u65ad\u662f\u5426\u9700\u8981\u7ee7\u7eed\u8ddf\u8fdb\u3002"
  216. risks = list_values(raw_message.get("risk_flags")) + list_values(analyzed.get("risk_flags"))
  217. row = {
  218. "\u8bb0\u5f55ID": record_id,
  219. "\u5ba2\u6237\u5e8f\u53f7": clean(customer.get("index")),
  220. "\u5ba2\u6237\u59d3\u540d/\u516c\u53f8": clean(customer.get("company")),
  221. "Facebook\u4e3b\u9875\u94fe\u63a5": clean(customer.get("facebook_link")),
  222. "Messenger\u7ebf\u7a0bID": clean(raw_thread.get("thread_id")),
  223. "\u6d88\u606f\u65f6\u95f4": clean(analyzed.get("message_time")) or clean(raw_message.get("message_time_raw")),
  224. "\u6d88\u606f\u65b9\u5411": direction,
  225. "\u53d1\u4ef6\u4eba": clean(raw_message.get("sender")),
  226. "\u539f\u6587\u8bed\u8a00": original_language,
  227. "\u5bf9\u8bdd\u539f\u6587": original,
  228. "\u4e2d\u6587\u7ffb\u8bd1": translation,
  229. "\u6d88\u606f\u7c7b\u578b": kind,
  230. "\u662f\u5426\u6709\u6548\u5ba2\u6237\u56de\u590d": "\u662f" if is_reply else "\u5426",
  231. "\u5408\u4f5c\u610f\u5411": "",
  232. "\u610f\u5411\u5224\u65ad\u4f9d\u636e": "",
  233. "\u4e0b\u4e00\u6b65\u5efa\u8bae": next_action,
  234. "\u540c\u6b65\u65f6\u95f4": sync_time,
  235. "\u6765\u6e90\u8d26\u53f7/Profile ID": clean(raw.get("profile_id")),
  236. "\u98ce\u9669\u6807\u8bb0": "\uff1b".join(dict.fromkeys(risks)),
  237. }
  238. output_rows.append(row)
  239. if is_reply:
  240. real_reply_rows.append(row)
  241. if real_reply_rows:
  242. latest = athread.get("latest_analysis") or {}
  243. latest_row = real_reply_rows[-1]
  244. latest_record_id = clean(latest.get("latest_reply_record_id"))
  245. if latest_record_id:
  246. latest_row = next((row for row in real_reply_rows if row.get("\u8bb0\u5f55ID") == latest_record_id), latest_row)
  247. summary = (
  248. clean(latest.get("chinese_summary"))
  249. or clean(latest_row.get("\u4e2d\u6587\u7ffb\u8bd1"))
  250. or clean(latest_row.get("\u5bf9\u8bdd\u539f\u6587"))
  251. or "\u5ba2\u6237\u5df2\u5728 Facebook Messenger \u56de\u590d\uff0c\u9700\u4eba\u5de5\u67e5\u770b\u5177\u4f53\u5185\u5bb9\u3002"
  252. )
  253. next_action = (
  254. clean(latest.get("next_action"))
  255. or clean(latest_row.get("\u4e0b\u4e00\u6b65\u5efa\u8bae"))
  256. or "\u8bf7\u4eba\u5de5\u67e5\u770b\u5ba2\u6237\u56de\u590d\uff0c\u5e76\u5224\u65ad\u662f\u5426\u9700\u8981\u7ee7\u7eed\u8ddf\u8fdb\u3002"
  257. )
  258. customer_updates.append({
  259. "index": clean(customer.get("index")),
  260. "company": clean(customer.get("company")),
  261. "facebook_link": clean(customer.get("facebook_link")),
  262. "latest_reply_at": clean(latest.get("latest_reply_at")) or clean(latest_row.get("\u6d88\u606f\u65f6\u95f4")),
  263. "summary": summary,
  264. "next_action": next_action,
  265. "next_followup": clean(latest.get("next_followup")),
  266. "latest_reply_record_id": clean(latest_row.get("\u8bb0\u5f55ID")),
  267. })
  268. return output_rows, customer_updates, warnings
  269. def append_or_update_conversations(ws, rows: Iterable[Dict[str, Any]]) -> Tuple[int, int]:
  270. headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
  271. existing = {
  272. clean(ws.cell(row=row, column=headers["记录ID"]).value): row
  273. for row in range(2, ws.max_row + 1)
  274. if clean(ws.cell(row=row, column=headers["记录ID"]).value)
  275. }
  276. immutable = {"记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID", "对话原文"}
  277. added = updated = 0
  278. for item in rows:
  279. record_id = clean(item.get("记录ID"))
  280. if record_id in existing:
  281. target, changed = existing[record_id], False
  282. for header in CONVERSATION_HEADERS:
  283. if header in immutable:
  284. continue
  285. value = item.get(header, "")
  286. if value not in (None, "") and ws.cell(target, headers[header]).value != value:
  287. ws.cell(target, headers[header]).value = value
  288. changed = True
  289. updated += int(changed)
  290. else:
  291. target = ws.max_row + 1
  292. for header in CONVERSATION_HEADERS:
  293. ws.cell(target, headers[header]).value = item.get(header, "")
  294. existing[record_id] = target
  295. added += 1
  296. ws.auto_filter.ref = ws.dimensions
  297. return added, updated
  298. def merge_status(existing: str) -> str:
  299. result = clean(existing)
  300. for phrase in OLD_REPLY_STATUS_PHRASES:
  301. result = result.replace(phrase, "")
  302. result = re.sub(r"[,\uFF0C;\uFF1B|]+", ",", result).strip(", ")
  303. parts = [clean(part) for part in result.split(",") if clean(part)]
  304. parts.append(FACEBOOK_REPLY_STATUS)
  305. return ",".join(dict.fromkeys(parts))
  306. def reply_note(update: Dict[str, Any]) -> str:
  307. return (
  308. f"{NOTE_START}\u6700\u65b0\u56de\u590d\u65f6\u95f4\uff1a{update['latest_reply_at']}\uff1b\u4e2d\u6587\u6458\u8981\uff1a{update['summary']}\uff1b"
  309. f"\u4e0b\u4e00\u6b65\u5efa\u8bae\uff1a{update['next_action']}{NOTE_END}"
  310. )
  311. def replace_reply_note(existing: str, block: str) -> str:
  312. existing = clean(existing)
  313. patterns = [
  314. re.compile(re.escape(NOTE_START) + r".*?" + re.escape(NOTE_END)),
  315. re.compile(re.escape("\u3010Facebook\u56de\u590d\u5206\u6790\u3011") + r".*?" + re.escape("\u3010/Facebook\u56de\u590d\u5206\u6790\u3011")),
  316. ]
  317. for pattern in patterns:
  318. if pattern.search(existing):
  319. return clean(pattern.sub(block, existing))
  320. return clean(existing + (" | " if existing else "") + block)
  321. def update_facebook_rows(wb, sheet_name: str, updates: Sequence[Dict[str, Any]]) -> Tuple[int, List[str]]:
  322. if sheet_name not in wb.sheetnames:
  323. raise KeyError(f"Sheet not found: {sheet_name}")
  324. ws = wb[sheet_name]
  325. columns = header_map(ws, FACEBOOK_ALIASES)
  326. required = {"index", "company", "link", "status", "followup", "note"}
  327. if missing := sorted(required - set(columns)):
  328. raise RuntimeError(f"Facebook sheet is missing columns: {', '.join(missing)}")
  329. by_index = {clean(item.get("index")): item for item in updates if clean(item.get("index"))}
  330. written, missing_customers, matched = 0, [], set()
  331. for row in range(2, ws.max_row + 1):
  332. row_index = clean(ws.cell(row, columns["index"]).value)
  333. update = by_index.get(row_index)
  334. if not update:
  335. continue
  336. if normalized_link(clean(ws.cell(row, columns["link"]).value)) != normalized_link(clean(update.get("facebook_link"))):
  337. missing_customers.append(f"{update.get('company')}: Facebook link mismatch")
  338. continue
  339. ws.cell(row, columns["status"]).value = merge_status(clean(ws.cell(row, columns["status"]).value))
  340. if clean(update.get("next_followup")):
  341. ws.cell(row, columns["followup"]).value = update["next_followup"]
  342. existing_note = clean(ws.cell(row, columns["note"]).value)
  343. ws.cell(row, columns["note"]).value = replace_reply_note(existing_note, reply_note(update))
  344. matched.add(row_index)
  345. written += 1
  346. for update in updates:
  347. if clean(update.get("index")) not in matched:
  348. missing_customers.append(f"{update.get('company')}: customer row not matched")
  349. return written, list(dict.fromkeys(missing_customers))
  350. def scan_question_marks(wb) -> Tuple[int, int]:
  351. cells = note_rows = 0
  352. for ws in wb.worksheets:
  353. note_columns = {idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value) in {"备注", "说明"}}
  354. bad_note_rows = set()
  355. for row in ws.iter_rows():
  356. for cell in row:
  357. if isinstance(cell.value, str) and QUESTION_MARK_RE.search(cell.value):
  358. cells += 1
  359. if cell.column in note_columns:
  360. bad_note_rows.add(cell.row)
  361. note_rows += len(bad_note_rows)
  362. return cells, note_rows
  363. def ensure_generated_text_clean(rows: Sequence[Dict[str, Any]], updates: Sequence[Dict[str, Any]]) -> None:
  364. for item in [*rows, *updates]:
  365. for key, value in item.items():
  366. if isinstance(value, str) and QUESTION_MARK_RE.search(value):
  367. raise ValueError(f"Repeated question marks detected before write: field={key}")
  368. def run_child(command: List[str]) -> Dict[str, Any]:
  369. completed = subprocess.run(command, text=True, encoding="utf-8", capture_output=True, check=True)
  370. try:
  371. return json.loads(completed.stdout)
  372. except json.JSONDecodeError:
  373. return {"stdout": completed.stdout.strip(), "stderr": completed.stderr.strip()}
  374. def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
  375. parser = argparse.ArgumentParser(description="Write translated Facebook conversations and unified reply records to Excel.")
  376. parser.add_argument("--excel", default="")
  377. parser.add_argument("--transcript", default="")
  378. parser.add_argument("--analysis", default="")
  379. parser.add_argument("--facebook-sheet", default=FACEBOOK_SHEET)
  380. parser.add_argument("--conversation-sheet", default=CONVERSATION_SHEET)
  381. parser.add_argument("--write-workbook", action="store_true")
  382. parser.add_argument("--ensure-sheet-only", action="store_true")
  383. parser.add_argument("--refresh-summary", action="store_true")
  384. parser.add_argument("--refresh-dashboard", action="store_true")
  385. parser.add_argument("--latest-dashboard-dir", default="dashboards/latest")
  386. parser.add_argument("--run-id", default="")
  387. parser.add_argument("--output", default="")
  388. parser.add_argument("--no-backup", action="store_true")
  389. return parser.parse_args(argv)
  390. def main(argv: Optional[Sequence[str]] = None) -> int:
  391. args = parse_args(argv)
  392. run_id = args.run_id or new_run_id("facebook_conversation_write")
  393. resolved = resolve_workbook_path(args.excel, create_from_template=bool(args.write_workbook))
  394. workbook = resolved.get("path")
  395. if not workbook:
  396. raise FileNotFoundError("No outreach workbook found. Pass --excel.")
  397. workbook_path = Path(workbook)
  398. lock_path = workbook_path.with_name("~$" + workbook_path.name)
  399. if args.write_workbook and lock_path.exists():
  400. raise PermissionError(f"Workbook appears to be open: {lock_path}")
  401. rows, updates, warnings = [], [], []
  402. if not args.ensure_sheet_only:
  403. if not args.transcript or not args.analysis:
  404. raise ValueError("--transcript and --analysis are required unless --ensure-sheet-only is used")
  405. rows, updates, warnings = merge_threads(read_json(Path(args.transcript)), read_json(Path(args.analysis)))
  406. ensure_generated_text_clean(rows, updates)
  407. report_path = resolve_artifact_path(
  408. args.output, kind="facebook_conversation_write",
  409. default_name="facebook_conversation_write_report.json", run_id=run_id,
  410. )
  411. report: Dict[str, Any] = {
  412. "schema_version": "4.29",
  413. "run_id": run_id,
  414. "workbook": str(workbook_path),
  415. "dry_run": not args.write_workbook,
  416. "conversation_rows_ready": len(rows),
  417. "customer_updates_ready": len(updates),
  418. "facebook_reply_customers_ready": len(updates),
  419. "warnings": warnings,
  420. }
  421. if not args.write_workbook:
  422. write_json(report_path, report)
  423. print(json.dumps({"report": str(report_path), **report}, ensure_ascii=False, indent=2))
  424. return 0
  425. backup_path = None if args.no_backup else create_backup_once(
  426. workbook_path, purpose="facebook_conversations", run_id=run_id,
  427. )
  428. wb = load_workbook(workbook_path)
  429. before_question_cells, before_question_notes = scan_question_marks(wb)
  430. if before_question_cells:
  431. raise ValueError(
  432. f"Workbook already contains repeated-question-mark corruption: cells={before_question_cells}, "
  433. f"note_rows={before_question_notes}"
  434. )
  435. conversation_ws = ensure_conversation_sheet(wb, args.conversation_sheet)
  436. added, conversation_updated = append_or_update_conversations(conversation_ws, rows)
  437. customer_rows_updated, missing_customers = (
  438. update_facebook_rows(wb, args.facebook_sheet, updates) if updates else (0, [])
  439. )
  440. if scan_question_marks(wb)[0]:
  441. raise ValueError("Repeated question marks detected before save")
  442. wb.save(workbook_path)
  443. summary_report, dashboard_report = {}, {}
  444. refresh_summary = bool(args.refresh_summary or args.refresh_dashboard)
  445. if refresh_summary:
  446. summary_report = run_child([
  447. sys.executable, str(SKILL_ROOT / "scripts" / "common" / "build_customer_summary.py"),
  448. "--excel", str(workbook_path), "--write-summary", "--no-backup", "--run-id", run_id,
  449. ])
  450. if args.refresh_dashboard:
  451. dashboard_report = run_child([
  452. sys.executable, str(SKILL_ROOT / "scripts" / "dashboard" / "build_dashboard.py"),
  453. "--excel", str(workbook_path), "--run-id", run_id,
  454. "--latest-dir", args.latest_dashboard_dir,
  455. ])
  456. reopened = load_workbook(workbook_path, data_only=True, read_only=True)
  457. question_mark_cells, question_mark_note_rows = scan_question_marks(reopened)
  458. reopened.close()
  459. report.update({
  460. "dry_run": False,
  461. "backup": str(backup_path) if backup_path else "",
  462. "conversation_rows_added": added,
  463. "conversation_rows_updated": conversation_updated,
  464. "customer_rows_updated": customer_rows_updated,
  465. "missing_customers": missing_customers,
  466. "question_mark_cells": question_mark_cells,
  467. "question_mark_note_rows": question_mark_note_rows,
  468. "summary_refreshed": refresh_summary,
  469. "summary_report": summary_report,
  470. "dashboard_refreshed": bool(args.refresh_dashboard),
  471. "dashboard_report": dashboard_report,
  472. })
  473. config_path = project_root(workbook_path.parent) / "feishu_sync_config.json"
  474. if config_path.exists():
  475. try:
  476. config = read_json(config_path)
  477. except Exception as exc:
  478. config = {"enabled": False, "config_error": str(exc)}
  479. report["feishu_sync"] = {
  480. "config_found": True,
  481. "enabled": bool(config.get("enabled")),
  482. "sync_required": bool(config.get("enabled")),
  483. "conversation_sheet_name": config.get("conversation_sheet_name", args.conversation_sheet),
  484. "sync_conversation_sheet": config.get("sync_conversation_sheet", True),
  485. "agent_action": "Use lark-sheets after local write; Python never stores Feishu credentials.",
  486. }
  487. else:
  488. report["feishu_sync"] = {
  489. "config_found": False,
  490. "enabled": False,
  491. "sync_required": False,
  492. "message": "本地建联表已更新;未发现 feishu_sync_config.json,飞书同步未执行。",
  493. }
  494. write_json(report_path, report)
  495. print(json.dumps({"report": str(report_path), **report}, ensure_ascii=False, indent=2))
  496. return 3 if question_mark_cells or missing_customers else 0
  497. if __name__ == "__main__":
  498. raise SystemExit(main())