build_dashboard.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """Build a visual HTML dashboard from the consolidated outreach workbook."""
  4. from __future__ import annotations
  5. import argparse
  6. import json
  7. import re
  8. import shutil
  9. import sys
  10. from collections import Counter
  11. from datetime import datetime
  12. from pathlib import Path
  13. from typing import Any, Dict, List, Optional, Sequence, Tuple
  14. from openpyxl import load_workbook
  15. SCRIPT_DIR = Path(__file__).resolve().parent
  16. SKILL_ROOT = SCRIPT_DIR.parents[1]
  17. COMMON_DIR = SKILL_ROOT / "scripts" / "common"
  18. if str(COMMON_DIR) not in sys.path:
  19. sys.path.insert(0, str(COMMON_DIR))
  20. from artifact_manager import new_run_id, resolve_artifact_path # type: ignore # noqa: E402
  21. from workbook_resolver import resolve_workbook_path # type: ignore # noqa: E402
  22. SUMMARY_SHEET_ALIASES = ["客户信息汇总表", "客户信息汇总"]
  23. CONVERSATION_SHEET = "Facebook对话记录"
  24. DEFAULT_TITLE = "五菱海外客户建联中台"
  25. FACEBOOK_INTENTS = ["明确有意向", "潜在意向", "需澄清", "暂不考虑", "明确拒绝"]
  26. HEADER_ALIASES = {
  27. "company": ["公司姓名", "客户姓名/公司", "公司名称", "客户名称", "公司/客户", "Name", "Company"],
  28. "country": ["国家", "Country"],
  29. "city": ["城市", "City"],
  30. "customer_type": ["客户类型", "细分客户类型", "Customer Type"],
  31. "website": ["官网链接", "公司官网", "官网", "官方网站", "Website"],
  32. "contact": ["联系人", "姓名", "Contact"],
  33. "position": ["职位", "职务", "Position", "Title"],
  34. "personal_email": ["个人邮箱", "个人邮箱(不一定有效)"],
  35. "contact_phone": ["联系人电话", "个人电话", "联系电话"],
  36. "facebook_link": ["Facebook主页链接", "Facebook链接", "facebook链接", "主页/链接"],
  37. "linkedin_link": ["linkined主页链接", "LinkedIn主页链接", "LinkedIn链接", "linkin链接", "linkin连接"],
  38. "google_maps_link": ["google map链接", "Google Maps链接", "Google Map链接", "地图链接"],
  39. "public_phone": ["公共电话/WhatsApp", "电话/WhatsApp", "公司公共电话", "电话", "WhatsApp"],
  40. "public_email": ["公共邮箱", "邮箱", "公司公共邮箱(任一有效即可)", "公司公共邮箱", "Email"],
  41. "attribute": ["客户属性", "客户大类", "Customer Attribute"],
  42. "status": ["建联状态", "建联情况", "状态", "Status"],
  43. "next_followup": ["下次跟进", "下次跟进时间", "Next Follow-up"],
  44. "note": ["备注", "说明", "Notes"],
  45. }
  46. CONVERSATION_HEADER_ALIASES = {
  47. "record_id": ["记录ID"],
  48. "customer_index": ["客户序号"],
  49. "company": ["客户姓名/公司", "公司姓名", "公司名称"],
  50. "facebook_link": ["Facebook主页链接", "主页/链接"],
  51. "message_time": ["消息时间"],
  52. "direction": ["消息方向"],
  53. "translation": ["中文翻译"],
  54. "effective": ["是否有效客户回复"],
  55. "intent": ["合作意向"],
  56. "intent_reason": ["意向判断依据"],
  57. "next_action": ["下一步建议"],
  58. }
  59. CONTACTED_KEYWORDS = ["已发送邮件", "已发邮件", "邮件已发送", "邮件发送成功", "已发私信", "已关注并私信", "已建联", "等待回复", "已回复", "有意向"]
  60. BOUNCED_KEYWORDS = ["邮件退回", "退回", "拒收", "无法送达", "域名不存在", "邮箱不存在", "发送失败"]
  61. EXCLUDED_KEYWORDS = ["已剔除", "剔除", "跳过", "低优先级", "skip_"]
  62. REVIEW_KEYWORDS = ["待确认", "人工复核", "需人工", "需确认", "信息不足"]
  63. SOURCE_PATTERNS = [
  64. ("Facebook", re.compile(r"facebook", re.I)),
  65. ("LinkedIn", re.compile(r"linkedin|linkin", re.I)),
  66. ("Google Maps", re.compile(r"google\s*maps|google map|maps", re.I)),
  67. ("Moteur.ma", re.compile(r"moteur", re.I)),
  68. ("当地汽车网站", re.compile(r"当地汽车网站|OtoMoto|Wandaloo|Kerix|Kompass|Maroc Annuaire|Telecontact", re.I)),
  69. ("平台与行业渠道", re.compile(r"协会商会|平台与行业渠道|商会|协会")),
  70. ]
  71. def clean(value: Any) -> str:
  72. if value is None:
  73. return ""
  74. return re.sub(r"\s+", " ", str(value).strip())
  75. def find_sheet_name(wb, requested: str) -> str:
  76. if requested and requested in wb.sheetnames:
  77. return requested
  78. for candidate in SUMMARY_SHEET_ALIASES:
  79. if candidate in wb.sheetnames:
  80. return candidate
  81. raise ValueError(f"未找到客户信息汇总 Sheet,可用 Sheet:{', '.join(wb.sheetnames)}")
  82. def header_map(ws) -> Dict[str, int]:
  83. raw = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
  84. mapped: Dict[str, int] = {}
  85. for key, aliases in HEADER_ALIASES.items():
  86. for alias in aliases:
  87. if alias in raw:
  88. mapped[key] = raw[alias]
  89. break
  90. return mapped
  91. def row_value(ws, row: int, headers: Dict[str, int], key: str) -> str:
  92. col = headers.get(key)
  93. if not col:
  94. return ""
  95. return clean(ws.cell(row=row, column=col).value)
  96. def is_effective_row(row: Dict[str, str]) -> bool:
  97. evidence = ["company", "website", "facebook_link", "linkedin_link", "google_maps_link", "public_email", "personal_email", "public_phone", "contact_phone", "note"]
  98. return any(row.get(key) for key in evidence)
  99. def read_summary_rows(workbook_path: Path, sheet_name: str) -> Tuple[str, List[Dict[str, str]]]:
  100. wb = load_workbook(workbook_path, data_only=True, read_only=True)
  101. actual_sheet = find_sheet_name(wb, sheet_name)
  102. ws = wb[actual_sheet]
  103. headers = header_map(ws)
  104. rows: List[Dict[str, str]] = []
  105. for row_idx in range(2, ws.max_row + 1):
  106. row = {key: row_value(ws, row_idx, headers, key) for key in HEADER_ALIASES}
  107. row["_excel_row"] = str(row_idx)
  108. if is_effective_row(row):
  109. rows.append(row)
  110. return actual_sheet, rows
  111. def normalized_link(value: str) -> str:
  112. value = clean(value).casefold().split("?", 1)[0].rstrip("/")
  113. return value.removeprefix("https://").removeprefix("http://").removeprefix("www.")
  114. def customer_key(company: str, facebook_link: str, customer_index: str = "") -> str:
  115. link = normalized_link(facebook_link)
  116. if link:
  117. return "link:" + link
  118. name = re.sub(r"[^0-9a-z\u00c0-\u024f\u0600-\u06ff\u4e00-\u9fff]+", "", clean(company).casefold())
  119. return "name:" + name if name else "index:" + clean(customer_index)
  120. def read_conversation_rows(workbook_path: Path, sheet_name: str) -> List[Dict[str, str]]:
  121. wb = load_workbook(workbook_path, data_only=True, read_only=True)
  122. if sheet_name not in wb.sheetnames:
  123. return []
  124. ws = wb[sheet_name]
  125. raw_headers = {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, aliases in CONVERSATION_HEADER_ALIASES.items():
  128. for alias in aliases:
  129. if alias in raw_headers:
  130. mapped[key] = raw_headers[alias]
  131. break
  132. if "record_id" not in mapped:
  133. return []
  134. rows: List[Dict[str, str]] = []
  135. for row_idx in range(2, ws.max_row + 1):
  136. row = {
  137. key: clean(ws.cell(row=row_idx, column=column).value)
  138. for key, column in mapped.items()
  139. }
  140. if row.get("record_id"):
  141. rows.append(row)
  142. return rows
  143. def message_datetime(value: str) -> datetime:
  144. value = clean(value)
  145. if not value:
  146. return datetime.min
  147. try:
  148. return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
  149. except ValueError:
  150. match = re.search(r"(20\d{2})[-/](\d{1,2})[-/](\d{1,2})", value)
  151. if match:
  152. return datetime(int(match.group(1)), int(match.group(2)), int(match.group(3)))
  153. return datetime.min
  154. def build_conversation_insights(rows: Sequence[Dict[str, str]]) -> Dict[str, Any]:
  155. sent_customers = set()
  156. replied_customers = set()
  157. latest_by_customer: Dict[str, Dict[str, str]] = {}
  158. reply_trend: Counter = Counter()
  159. for row in sorted(rows, key=lambda item: message_datetime(item.get("message_time", ""))):
  160. key = customer_key(row.get("company", ""), row.get("facebook_link", ""), row.get("customer_index", ""))
  161. if row.get("direction") == "我方发送":
  162. sent_customers.add(key)
  163. if row.get("direction") != "客户回复" or row.get("effective") != "是":
  164. continue
  165. replied_customers.add(key)
  166. if row.get("intent") in FACEBOOK_INTENTS:
  167. latest_by_customer[key] = row
  168. parsed = message_datetime(row.get("message_time", ""))
  169. if parsed != datetime.min and (datetime.now() - parsed).days <= 30:
  170. reply_trend[parsed.strftime("%Y-%m-%d")] += 1
  171. intent_counter = Counter(
  172. row.get("intent") or "未分析" for row in latest_by_customer.values()
  173. )
  174. high_intent = [
  175. {
  176. "company": row.get("company", ""),
  177. "intent": row.get("intent", ""),
  178. "latest_reply": row.get("translation", ""),
  179. "reason": row.get("intent_reason", ""),
  180. "next_action": row.get("next_action", ""),
  181. "message_time": row.get("message_time", ""),
  182. }
  183. for row in latest_by_customer.values()
  184. if row.get("intent") in {"明确有意向", "潜在意向"}
  185. ]
  186. pending = [
  187. item for item in high_intent
  188. if item.get("next_action")
  189. ]
  190. trend = [
  191. {"name": day, "count": count}
  192. for day, count in sorted(reply_trend.items())
  193. ]
  194. return {
  195. "sent_customers": sent_customers,
  196. "replied_customers": replied_customers,
  197. "latest_by_customer": latest_by_customer,
  198. "intent_counter": intent_counter,
  199. "reply_trend": trend,
  200. "high_intent": sorted(high_intent, key=lambda item: item.get("message_time", ""), reverse=True),
  201. "pending_followups": sorted(pending, key=lambda item: item.get("message_time", ""), reverse=True),
  202. }
  203. def contains_any(text: str, keywords: Sequence[str]) -> bool:
  204. lowered = text.casefold()
  205. return any(keyword.casefold() in lowered for keyword in keywords)
  206. def has_email(row: Dict[str, str]) -> bool:
  207. return bool(row.get("public_email") or row.get("personal_email"))
  208. def has_phone(row: Dict[str, str]) -> bool:
  209. return bool(row.get("public_phone") or row.get("contact_phone"))
  210. def has_any_link(row: Dict[str, str]) -> bool:
  211. return bool(row.get("website") or row.get("facebook_link") or row.get("linkedin_link") or row.get("google_maps_link"))
  212. def is_excluded(row: Dict[str, str]) -> bool:
  213. return contains_any(" ".join([row.get("status", ""), row.get("note", "")]), EXCLUDED_KEYWORDS)
  214. def is_bounced(row: Dict[str, str]) -> bool:
  215. return contains_any(" ".join([row.get("status", ""), row.get("note", "")]), BOUNCED_KEYWORDS)
  216. def is_contacted(row: Dict[str, str]) -> bool:
  217. text = " ".join([row.get("status", ""), row.get("note", "")])
  218. return contains_any(text, CONTACTED_KEYWORDS) and not is_bounced(row)
  219. def needs_review(row: Dict[str, str]) -> bool:
  220. text = " ".join([row.get("customer_type", ""), row.get("attribute", ""), row.get("note", "")])
  221. return contains_any(text, REVIEW_KEYWORDS)
  222. def is_contactable(row: Dict[str, str]) -> bool:
  223. return (has_email(row) or has_phone(row) or has_any_link(row)) and not is_excluded(row)
  224. def row_sources(row: Dict[str, str]) -> List[str]:
  225. sources: List[str] = []
  226. if row.get("facebook_link"):
  227. sources.append("Facebook")
  228. if row.get("linkedin_link"):
  229. sources.append("LinkedIn")
  230. if row.get("google_maps_link"):
  231. sources.append("Google Maps")
  232. note = " ".join([row.get("note", ""), row.get("website", "")])
  233. for label, pattern in SOURCE_PATTERNS:
  234. if pattern.search(note) and label not in sources:
  235. sources.append(label)
  236. return sources or ["未标明来源"]
  237. def pct(part: int, total: int) -> float:
  238. return round(part * 100 / total, 1) if total else 0.0
  239. def top_counter(counter: Counter, limit: int) -> List[Dict[str, Any]]:
  240. total = sum(counter.values())
  241. return [{"name": name or "未填写", "count": count, "rate": pct(count, total)} for name, count in counter.most_common(limit)]
  242. def display_contact(row: Dict[str, str]) -> str:
  243. values = [row.get("public_email"), row.get("personal_email"), row.get("public_phone"), row.get("contact_phone")]
  244. return next((value for value in values if value), "有主页/链接" if has_any_link(row) else "")
  245. def build_dashboard_data(
  246. rows: Sequence[Dict[str, str]],
  247. conversations: Sequence[Dict[str, str]],
  248. workbook_path: Path,
  249. sheet_name: str,
  250. run_id: str,
  251. top_n: int,
  252. ) -> Dict[str, Any]:
  253. valid_rows = [row for row in rows if not is_excluded(row)]
  254. contactable_rows = [row for row in valid_rows if is_contactable(row)]
  255. contacted_rows = [row for row in valid_rows if is_contacted(row)]
  256. bounced_rows = [row for row in rows if is_bounced(row)]
  257. review_rows = [row for row in valid_rows if needs_review(row)]
  258. email_rows = [row for row in valid_rows if has_email(row)]
  259. phone_rows = [row for row in valid_rows if has_phone(row)]
  260. conversation = build_conversation_insights(conversations)
  261. facebook_sent = len(conversation["sent_customers"])
  262. facebook_replied = len(conversation["replied_customers"])
  263. latest_intents = conversation["latest_by_customer"]
  264. attribute_counter = Counter(row.get("attribute") or "未填写" for row in valid_rows)
  265. type_counter = Counter(row.get("customer_type") or "未填写" for row in valid_rows)
  266. status_counter = Counter(row.get("status") or "未填写" for row in rows)
  267. city_counter = Counter(row.get("city") or "未填写" for row in valid_rows)
  268. source_counter: Counter = Counter()
  269. for row in valid_rows:
  270. for source in row_sources(row):
  271. source_counter[source] += 1
  272. dashboard_rows = []
  273. for row in valid_rows:
  274. reply = latest_intents.get(customer_key(row.get("company", ""), row.get("facebook_link", "")), {})
  275. dashboard_rows.append({
  276. "row": row.get("_excel_row", ""),
  277. "company": row.get("company", ""),
  278. "city": row.get("city", ""),
  279. "attribute": row.get("attribute", ""),
  280. "type": row.get("customer_type", ""),
  281. "source": ";".join(row_sources(row)),
  282. "contact": display_contact(row),
  283. "status": row.get("status", ""),
  284. "has_email": has_email(row),
  285. "has_phone": has_phone(row),
  286. "contacted": is_contacted(row),
  287. "bounced": is_bounced(row),
  288. "review": needs_review(row),
  289. "note": row.get("note", "")[:220],
  290. "facebook_intent": reply.get("intent", ""),
  291. "latest_reply": reply.get("translation", "")[:160],
  292. "next_action": reply.get("next_action", "")[:160],
  293. })
  294. attention_rows = [row for row in dashboard_rows if row["contact"] and not row["contacted"] and not row["bounced"]][:top_n]
  295. return {
  296. "title": DEFAULT_TITLE,
  297. "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
  298. "run_id": run_id,
  299. "workbook": str(workbook_path),
  300. "sheet_name": sheet_name,
  301. "metrics": {
  302. "total_rows": len(rows),
  303. "valid_customers": len(valid_rows),
  304. "excluded_customers": len(rows) - len(valid_rows),
  305. "contactable_customers": len(contactable_rows),
  306. "contacted_customers": len(contacted_rows),
  307. "outreach_rate": pct(len(contacted_rows), len(contactable_rows)),
  308. "email_customers": len(email_rows),
  309. "email_coverage_rate": pct(len(email_rows), len(valid_rows)),
  310. "phone_customers": len(phone_rows),
  311. "phone_coverage_rate": pct(len(phone_rows), len(valid_rows)),
  312. "bounced_customers": len(bounced_rows),
  313. "bounce_rate": pct(len(bounced_rows), len(email_rows)),
  314. "needs_review_customers": len(review_rows),
  315. "review_rate": pct(len(review_rows), len(valid_rows)),
  316. "facebook_dm_customers": facebook_sent,
  317. "facebook_reply_customers": facebook_replied,
  318. "facebook_reply_rate": pct(facebook_replied, facebook_sent),
  319. "facebook_clear_intent_customers": sum(
  320. 1 for row in latest_intents.values() if row.get("intent") == "明确有意向"
  321. ),
  322. "facebook_potential_intent_customers": sum(
  323. 1 for row in latest_intents.values() if row.get("intent") == "潜在意向"
  324. ),
  325. "facebook_pending_followups": len(conversation["pending_followups"]),
  326. },
  327. "charts": {
  328. "attribute": top_counter(attribute_counter, top_n),
  329. "customer_type": top_counter(type_counter, top_n),
  330. "status": top_counter(status_counter, top_n),
  331. "source": top_counter(source_counter, top_n),
  332. "city": top_counter(city_counter, top_n),
  333. "facebook_intent": top_counter(conversation["intent_counter"], len(FACEBOOK_INTENTS)),
  334. "facebook_reply_trend": conversation["reply_trend"],
  335. },
  336. "dashboard_rows": dashboard_rows,
  337. "attention_customers": attention_rows,
  338. "facebook_high_intent_customers": conversation["high_intent"][:top_n],
  339. "facebook_pending_followups": conversation["pending_followups"][:top_n],
  340. "definitions": {
  341. "有效客户": "总表中未被备注或状态标记为已剔除、跳过、低优先级的客户。",
  342. "可建联客户": "有效客户中至少有邮箱、电话/WhatsApp、官网、Facebook、LinkedIn 或 Google Maps 入口之一。",
  343. "已建联客户": "状态或备注包含已发送邮件、已发私信、已关注并私信、等待回复、已回复、已建联或有意向,且未被标记退信/拒收。",
  344. "建联率": "已建联客户数 / 可建联客户数。",
  345. "邮箱覆盖率": "有个人邮箱或公共邮箱的有效客户数 / 有效客户数。",
  346. "退信率": "邮件退回、拒收、无法送达或域名/邮箱不存在客户数 / 有邮箱客户数。",
  347. "Facebook回复率": "Facebook对话记录中有有效客户回复的唯一客户数 / 已发送Facebook私信的唯一客户数;系统消息和自动回复不计入。",
  348. "合作意向": "按每个客户最新一条有效回复计算五级意向,同一客户多条消息只计一次。",
  349. },
  350. }
  351. def render_html(data: Dict[str, Any]) -> str:
  352. template = (SKILL_ROOT / "assets" / "dashboard_template.html").read_text(encoding="utf-8")
  353. payload = json.dumps(data, ensure_ascii=False).replace("<", "\\u003c")
  354. return template.replace("{{dashboard_json}}", payload)
  355. def write_outputs(data: Dict[str, Any], html_text: str, html_path: Path, json_path: Path, latest_dir: str = "") -> Dict[str, str]:
  356. html_path.parent.mkdir(parents=True, exist_ok=True)
  357. json_path.parent.mkdir(parents=True, exist_ok=True)
  358. html_path.write_text(html_text, encoding="utf-8")
  359. json_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
  360. copied_latest = ""
  361. if latest_dir:
  362. target_dir = Path(latest_dir).expanduser()
  363. if not target_dir.is_absolute():
  364. target_dir = Path.cwd() / target_dir
  365. target_dir.mkdir(parents=True, exist_ok=True)
  366. latest_html = target_dir / "customer_dashboard.html"
  367. latest_json = target_dir / "dashboard_data.json"
  368. shutil.copy2(html_path, latest_html)
  369. shutil.copy2(json_path, latest_json)
  370. copied_latest = str(latest_html)
  371. return {"html": str(html_path), "json": str(json_path), "latest_html": copied_latest}
  372. def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
  373. parser = argparse.ArgumentParser(description="Generate a Wuling outreach dashboard from 客户信息汇总表.")
  374. parser.add_argument("--excel", default="", help="Workbook path. If omitted, resolve the project workbook by skill rules.")
  375. parser.add_argument("--summary-sheet", default="客户信息汇总表", help="Summary sheet name.")
  376. parser.add_argument("--conversation-sheet", default=CONVERSATION_SHEET, help="Facebook conversation sheet name.")
  377. parser.add_argument("--output-html", default="", help="Optional HTML output path. Bare filename goes to runs/.")
  378. parser.add_argument("--output-json", default="", help="Optional JSON output path. Bare filename goes to runs/.")
  379. parser.add_argument("--run-id", default="", help="Run ID used for artifacts.")
  380. parser.add_argument("--top-n", type=int, default=12, help="Top N categories and attention rows.")
  381. parser.add_argument("--latest-dir", default="", help="Optional stable directory for a copy of the latest dashboard.")
  382. return parser.parse_args(argv)
  383. def main(argv: Optional[Sequence[str]] = None) -> int:
  384. args = parse_args(argv)
  385. run_id = args.run_id or new_run_id("dashboard")
  386. resolved = resolve_workbook_path(args.excel, create_from_template=False)
  387. workbook_path = resolved.get("path")
  388. if not workbook_path:
  389. raise FileNotFoundError("No outreach workbook found. Pass --excel or create one from the skill blank template in write-enabled workflows.")
  390. workbook_path = Path(workbook_path)
  391. actual_sheet, rows = read_summary_rows(workbook_path, args.summary_sheet)
  392. conversations = read_conversation_rows(workbook_path, args.conversation_sheet)
  393. data = build_dashboard_data(rows, conversations, workbook_path, actual_sheet, run_id, args.top_n)
  394. html_text = render_html(data)
  395. html_path = resolve_artifact_path(args.output_html, kind="dashboard", default_name="customer_dashboard.html", run_id=run_id)
  396. json_path = resolve_artifact_path(args.output_json, kind="dashboard", default_name="dashboard_data.json", run_id=run_id)
  397. outputs = write_outputs(data, html_text, html_path, json_path, args.latest_dir)
  398. report = {
  399. "workbook": str(workbook_path),
  400. "summary_sheet": actual_sheet,
  401. "conversation_sheet": args.conversation_sheet if conversations else "",
  402. "conversation_rows": len(conversations),
  403. "run_id": run_id,
  404. "outputs": outputs,
  405. "metrics": data["metrics"],
  406. }
  407. print(json.dumps(report, ensure_ascii=False, indent=2))
  408. return 0
  409. if __name__ == "__main__":
  410. raise SystemExit(main())