|
@@ -0,0 +1,551 @@
|
|
|
|
|
+#!/usr/bin/env python3
|
|
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
|
|
+"""Validate translated Facebook conversations and write them to the workbook."""
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import argparse
|
|
|
|
|
+import json
|
|
|
|
|
+import re
|
|
|
|
|
+import subprocess
|
|
|
|
|
+import sys
|
|
|
|
|
+from datetime import date, datetime, timedelta
|
|
|
|
|
+from pathlib import Path
|
|
|
|
|
+from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
|
|
|
+
|
|
|
|
|
+from openpyxl import load_workbook
|
|
|
|
|
+from openpyxl.styles import Font, PatternFill
|
|
|
|
|
+from openpyxl.utils import get_column_letter
|
|
|
|
|
+
|
|
|
|
|
+SCRIPT_DIR = Path(__file__).resolve().parent
|
|
|
|
|
+SKILL_ROOT = SCRIPT_DIR.parents[1]
|
|
|
|
|
+COMMON_DIR = SKILL_ROOT / "scripts" / "common"
|
|
|
|
|
+if str(COMMON_DIR) not in sys.path:
|
|
|
|
|
+ sys.path.insert(0, str(COMMON_DIR))
|
|
|
|
|
+
|
|
|
|
|
+from artifact_manager import create_backup_once, new_run_id, project_root, resolve_artifact_path # type: ignore # noqa: E402
|
|
|
|
|
+from workbook_resolver import resolve_workbook_path # type: ignore # noqa: E402
|
|
|
|
|
+
|
|
|
|
|
+FACEBOOK_SHEET = "Facebook"
|
|
|
|
|
+CONVERSATION_SHEET = "Facebook对话记录"
|
|
|
|
|
+CONVERSATION_HEADERS = [
|
|
|
|
|
+ "记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID",
|
|
|
|
|
+ "消息时间", "消息方向", "发件人", "原文语言", "对话原文", "中文翻译", "消息类型",
|
|
|
|
|
+ "是否有效客户回复", "合作意向", "意向判断依据", "下一步建议", "同步时间",
|
|
|
|
|
+ "来源账号/Profile ID", "风险标记",
|
|
|
|
|
+]
|
|
|
|
|
+INTENTS = ["明确有意向", "潜在意向", "需澄清", "暂不考虑", "明确拒绝"]
|
|
|
|
|
+STATUS_BY_INTENT = {
|
|
|
|
|
+ "明确有意向": "已回复,有合作意向",
|
|
|
|
|
+ "潜在意向": "已回复,待跟进",
|
|
|
|
|
+ "需澄清": "已回复,待澄清",
|
|
|
|
|
+ "暂不考虑": "已回复,暂不考虑",
|
|
|
|
|
+ "明确拒绝": "已回复,明确拒绝",
|
|
|
|
|
+}
|
|
|
|
|
+FOLLOWUP_DAYS = {
|
|
|
|
|
+ "明确有意向": ("business", 1),
|
|
|
|
|
+ "潜在意向": ("business", 3),
|
|
|
|
|
+ "需澄清": ("business", 2),
|
|
|
|
|
+ "暂不考虑": ("calendar", 30),
|
|
|
|
|
+ "明确拒绝": ("none", 0),
|
|
|
|
|
+}
|
|
|
|
|
+REPLY_STATUS_PHRASES = list(STATUS_BY_INTENT.values())
|
|
|
|
|
+NOTE_START = "【Facebook回复分析】"
|
|
|
|
|
+NOTE_END = "【/Facebook回复分析】"
|
|
|
|
|
+QUESTION_MARK_RE = re.compile(r"\?{3,}")
|
|
|
|
|
+CJK_RE = re.compile(r"[\u3400-\u9fff]")
|
|
|
|
|
+FACEBOOK_ALIASES = {
|
|
|
|
|
+ "index": ["序号", "编号", "ID"],
|
|
|
|
|
+ "company": ["客户姓名/公司", "公司名称", "公司姓名", "客户名称"],
|
|
|
|
|
+ "link": ["主页/链接", "Facebook主页链接", "Facebook链接", "facebook链接"],
|
|
|
|
|
+ "status": ["建联状态", "建联情况"],
|
|
|
|
|
+ "followup": ["下次跟进", "下次跟进时间"],
|
|
|
|
|
+ "note": ["备注", "说明"],
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def clean(value: Any) -> str:
|
|
|
|
|
+ if value is None:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ return re.sub(r"\s+", " ", str(value).strip())
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def now_text() -> str:
|
|
|
|
|
+ return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def read_json(path: Path) -> Dict[str, Any]:
|
|
|
|
|
+ return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def write_json(path: Path, data: Dict[str, Any]) -> None:
|
|
|
|
|
+ path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalized_link(value: str) -> str:
|
|
|
|
|
+ value = clean(value).casefold().split("?", 1)[0].rstrip("/")
|
|
|
|
|
+ return value.removeprefix("https://").removeprefix("http://").removeprefix("www.")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def list_values(value: Any) -> List[str]:
|
|
|
|
|
+ if isinstance(value, list):
|
|
|
|
|
+ return [clean(item) for item in value if clean(item)]
|
|
|
|
|
+ return [part for part in re.split(r"[;;|]+", clean(value)) if part] if clean(value) else []
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def analysis_key(thread: Dict[str, Any]) -> Tuple[str, str, str]:
|
|
|
|
|
+ return (
|
|
|
|
|
+ clean(thread.get("customer_index")),
|
|
|
|
|
+ clean(thread.get("thread_id")),
|
|
|
|
|
+ normalized_link(clean(thread.get("facebook_link"))),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def raw_thread_key(thread: Dict[str, Any]) -> Tuple[str, str, str]:
|
|
|
|
|
+ customer = thread.get("customer") or {}
|
|
|
|
|
+ return (
|
|
|
|
|
+ clean(customer.get("index")),
|
|
|
|
|
+ clean(thread.get("thread_id") or customer.get("expected_identity")),
|
|
|
|
|
+ normalized_link(clean(customer.get("facebook_link"))),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def fallback_analysis_match(raw_thread: Dict[str, Any], candidates: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
|
|
|
+ raw_key = raw_thread_key(raw_thread)
|
|
|
|
|
+ for candidate in candidates:
|
|
|
|
|
+ candidate_key = analysis_key(candidate)
|
|
|
|
|
+ checks = [left and right and left == right for left, right in zip(raw_key, candidate_key)]
|
|
|
|
|
+ if sum(checks) >= 2:
|
|
|
|
|
+ return candidate
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def chinese_or_same(original: str, translated: str) -> str:
|
|
|
|
|
+ original, translated = clean(original), clean(translated)
|
|
|
|
|
+ if translated:
|
|
|
|
|
+ return translated
|
|
|
|
|
+ if not original:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ if CJK_RE.search(original):
|
|
|
|
|
+ return original
|
|
|
|
|
+ raise ValueError(f"Missing Chinese translation for message: {original[:80]}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def validate_intent(value: str) -> str:
|
|
|
|
|
+ value = clean(value)
|
|
|
|
|
+ if value not in INTENTS:
|
|
|
|
|
+ raise ValueError(f"Invalid cooperation intent: {value!r}. Expected one of {INTENTS}")
|
|
|
|
|
+ return value
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def parse_date(value: str) -> Optional[date]:
|
|
|
|
|
+ match = re.search(r"(20\d{2})[-/](\d{1,2})[-/](\d{1,2})", clean(value))
|
|
|
|
|
+ if not match:
|
|
|
|
|
+ return None
|
|
|
|
|
+ try:
|
|
|
|
|
+ return date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
|
|
|
+ except ValueError:
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def add_business_days(start: date, days: int) -> date:
|
|
|
|
|
+ result = start
|
|
|
|
|
+ while days > 0:
|
|
|
|
|
+ result += timedelta(days=1)
|
|
|
|
|
+ if result.weekday() < 5:
|
|
|
|
|
+ days -= 1
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def default_followup(intent: str, latest_reply_at: str, explicit: str = "") -> str:
|
|
|
|
|
+ if clean(explicit):
|
|
|
|
|
+ parsed = parse_date(explicit)
|
|
|
|
|
+ if not parsed:
|
|
|
|
|
+ raise ValueError(f"Invalid next_followup date: {explicit!r}")
|
|
|
|
|
+ return parsed.isoformat()
|
|
|
|
|
+ kind, amount = FOLLOWUP_DAYS[intent]
|
|
|
|
|
+ if kind == "none":
|
|
|
|
|
+ return ""
|
|
|
|
|
+ start = parse_date(latest_reply_at) or date.today()
|
|
|
|
|
+ return (add_business_days(start, amount) if kind == "business" else start + timedelta(days=amount)).isoformat()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def header_map(ws, aliases: Dict[str, List[str]]) -> Dict[str, int]:
|
|
|
|
|
+ raw = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value)}
|
|
|
|
|
+ mapped: Dict[str, int] = {}
|
|
|
|
|
+ for key, names in aliases.items():
|
|
|
|
|
+ for name in names:
|
|
|
|
|
+ if name in raw:
|
|
|
|
|
+ mapped[key] = raw[name]
|
|
|
|
|
+ break
|
|
|
|
|
+ return mapped
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def ensure_conversation_sheet(wb, sheet_name: str):
|
|
|
|
|
+ if sheet_name not in wb.sheetnames:
|
|
|
|
|
+ ws = wb.create_sheet(sheet_name)
|
|
|
|
|
+ ws.append(CONVERSATION_HEADERS)
|
|
|
|
|
+ else:
|
|
|
|
|
+ ws = wb[sheet_name]
|
|
|
|
|
+ existing = {clean(cell.value) for cell in ws[1] if clean(cell.value)}
|
|
|
|
|
+ for header in CONVERSATION_HEADERS:
|
|
|
|
|
+ if header not in existing:
|
|
|
|
|
+ ws.cell(row=1, column=ws.max_column + 1).value = header
|
|
|
|
|
+ header_fill = PatternFill(fill_type="solid", fgColor="D9EAF7")
|
|
|
|
|
+ for cell in ws[1]:
|
|
|
|
|
+ cell.font, cell.fill = Font(bold=True), header_fill
|
|
|
|
|
+ headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
|
|
|
|
|
+ widths = [38, 12, 28, 38, 24, 22, 12, 20, 12, 56, 56, 14, 18, 16, 52, 52, 22, 22, 32]
|
|
|
|
|
+ for header, width in zip(CONVERSATION_HEADERS, widths):
|
|
|
|
|
+ ws.column_dimensions[get_column_letter(headers[header])].width = width
|
|
|
|
|
+ ws.freeze_panes = "A2"
|
|
|
|
|
+ ws.auto_filter.ref = ws.dimensions
|
|
|
|
|
+ return ws
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def message_analysis_map(thread: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
|
|
|
|
+ return {clean(item.get("record_id")): item for item in thread.get("messages", []) if clean(item.get("record_id"))}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def merge_threads(raw: Dict[str, Any], analysis: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str]]:
|
|
|
|
|
+ analysis_threads = analysis.get("threads") or []
|
|
|
|
|
+ exact = {analysis_key(thread): thread for thread in analysis_threads}
|
|
|
|
|
+ output_rows: List[Dict[str, Any]] = []
|
|
|
|
|
+ customer_updates: List[Dict[str, Any]] = []
|
|
|
|
|
+ warnings: List[str] = []
|
|
|
|
|
+ sync_time = now_text()
|
|
|
|
|
+ for raw_thread in raw.get("threads") or []:
|
|
|
|
|
+ if raw_thread.get("status") != "collected":
|
|
|
|
|
+ warnings.append(f"{clean((raw_thread.get('customer') or {}).get('company'))}: {clean(raw_thread.get('status'))}")
|
|
|
|
|
+ continue
|
|
|
|
|
+ messages = raw_thread.get("messages") or []
|
|
|
|
|
+ if not messages:
|
|
|
|
|
+ continue
|
|
|
|
|
+ athread = exact.get(raw_thread_key(raw_thread)) or fallback_analysis_match(raw_thread, analysis_threads)
|
|
|
|
|
+ if not athread:
|
|
|
|
|
+ raise ValueError(f"Missing analysis thread for {raw_thread_key(raw_thread)}")
|
|
|
|
|
+ amap = message_analysis_map(athread)
|
|
|
|
|
+ customer = raw_thread.get("customer") or {}
|
|
|
|
|
+ effective_reply_ids: List[str] = []
|
|
|
|
|
+ for raw_message in messages:
|
|
|
|
|
+ record_id = clean(raw_message.get("record_id"))
|
|
|
|
|
+ if not record_id:
|
|
|
|
|
+ raise ValueError("Raw message is missing record_id")
|
|
|
|
|
+ analyzed = amap.get(record_id) or {}
|
|
|
|
|
+ direction = clean(raw_message.get("direction"))
|
|
|
|
|
+ kind = clean(raw_message.get("message_type")) or "文本"
|
|
|
|
|
+ original = clean(raw_message.get("original_text"))
|
|
|
|
|
+ translation = chinese_or_same(original, clean(analyzed.get("chinese_translation")))
|
|
|
|
|
+ original_language = clean(analyzed.get("original_language")) or ("zh" if CJK_RE.search(original) else "")
|
|
|
|
|
+ non_reply = direction != "客户回复" or kind in {"系统消息", "自动回复", "已读提示"}
|
|
|
|
|
+ effective = bool(analyzed.get("is_effective_customer_reply")) and not non_reply
|
|
|
|
|
+ intent = reason = action = ""
|
|
|
|
|
+ if effective:
|
|
|
|
|
+ intent = validate_intent(clean(analyzed.get("intent")))
|
|
|
|
|
+ reason, action = clean(analyzed.get("intent_reason")), clean(analyzed.get("next_action"))
|
|
|
|
|
+ if not reason or not action:
|
|
|
|
|
+ raise ValueError(f"Effective reply {record_id} requires intent_reason and next_action")
|
|
|
|
|
+ effective_reply_ids.append(record_id)
|
|
|
|
|
+ risks = list_values(raw_message.get("risk_flags")) + list_values(analyzed.get("risk_flags"))
|
|
|
|
|
+ output_rows.append({
|
|
|
|
|
+ "记录ID": record_id,
|
|
|
|
|
+ "客户序号": clean(customer.get("index")),
|
|
|
|
|
+ "客户姓名/公司": clean(customer.get("company")),
|
|
|
|
|
+ "Facebook主页链接": clean(customer.get("facebook_link")),
|
|
|
|
|
+ "Messenger线程ID": clean(raw_thread.get("thread_id")),
|
|
|
|
|
+ "消息时间": clean(analyzed.get("message_time")) or clean(raw_message.get("message_time_raw")),
|
|
|
|
|
+ "消息方向": direction,
|
|
|
|
|
+ "发件人": clean(raw_message.get("sender")),
|
|
|
|
|
+ "原文语言": original_language,
|
|
|
|
|
+ "对话原文": original,
|
|
|
|
|
+ "中文翻译": translation,
|
|
|
|
|
+ "消息类型": kind,
|
|
|
|
|
+ "是否有效客户回复": "是" if effective else "否",
|
|
|
|
|
+ "合作意向": intent,
|
|
|
|
|
+ "意向判断依据": reason,
|
|
|
|
|
+ "下一步建议": action,
|
|
|
|
|
+ "同步时间": sync_time,
|
|
|
|
|
+ "来源账号/Profile ID": clean(raw.get("profile_id")),
|
|
|
|
|
+ "风险标记": ";".join(dict.fromkeys(risks)),
|
|
|
|
|
+ })
|
|
|
|
|
+ latest = athread.get("latest_analysis") or {}
|
|
|
|
|
+ if effective_reply_ids:
|
|
|
|
|
+ latest_record_id = clean(latest.get("latest_reply_record_id"))
|
|
|
|
|
+ if latest_record_id not in effective_reply_ids:
|
|
|
|
|
+ raise ValueError(f"latest_reply_record_id must reference an effective reply for {clean(customer.get('company'))}")
|
|
|
|
|
+ latest_intent = validate_intent(clean(latest.get("intent")))
|
|
|
|
|
+ summary = clean(latest.get("chinese_summary"))
|
|
|
|
|
+ reason, next_action = clean(latest.get("intent_reason")), clean(latest.get("next_action"))
|
|
|
|
|
+ latest_reply_at = clean(latest.get("latest_reply_at"))
|
|
|
|
|
+ if not all([summary, reason, next_action, latest_reply_at]):
|
|
|
|
|
+ raise ValueError(f"latest_analysis is incomplete for {clean(customer.get('company'))}")
|
|
|
|
|
+ customer_updates.append({
|
|
|
|
|
+ "index": clean(customer.get("index")),
|
|
|
|
|
+ "company": clean(customer.get("company")),
|
|
|
|
|
+ "facebook_link": clean(customer.get("facebook_link")),
|
|
|
|
|
+ "latest_reply_at": latest_reply_at,
|
|
|
|
|
+ "summary": summary,
|
|
|
|
|
+ "intent": latest_intent,
|
|
|
|
|
+ "intent_reason": reason,
|
|
|
|
|
+ "next_action": next_action,
|
|
|
|
|
+ "next_followup": default_followup(latest_intent, latest_reply_at, clean(latest.get("next_followup"))),
|
|
|
|
|
+ "latest_reply_record_id": latest_record_id,
|
|
|
|
|
+ })
|
|
|
|
|
+ return output_rows, customer_updates, warnings
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def append_or_update_conversations(ws, rows: Iterable[Dict[str, Any]]) -> Tuple[int, int]:
|
|
|
|
|
+ headers = {clean(cell.value): idx for idx, cell in enumerate(ws[1], start=1)}
|
|
|
|
|
+ existing = {
|
|
|
|
|
+ clean(ws.cell(row=row, column=headers["记录ID"]).value): row
|
|
|
|
|
+ for row in range(2, ws.max_row + 1)
|
|
|
|
|
+ if clean(ws.cell(row=row, column=headers["记录ID"]).value)
|
|
|
|
|
+ }
|
|
|
|
|
+ immutable = {"记录ID", "客户序号", "客户姓名/公司", "Facebook主页链接", "Messenger线程ID", "对话原文"}
|
|
|
|
|
+ added = updated = 0
|
|
|
|
|
+ for item in rows:
|
|
|
|
|
+ record_id = clean(item.get("记录ID"))
|
|
|
|
|
+ if record_id in existing:
|
|
|
|
|
+ target, changed = existing[record_id], False
|
|
|
|
|
+ for header in CONVERSATION_HEADERS:
|
|
|
|
|
+ if header in immutable:
|
|
|
|
|
+ continue
|
|
|
|
|
+ value = item.get(header, "")
|
|
|
|
|
+ if value not in (None, "") and ws.cell(target, headers[header]).value != value:
|
|
|
|
|
+ ws.cell(target, headers[header]).value = value
|
|
|
|
|
+ changed = True
|
|
|
|
|
+ updated += int(changed)
|
|
|
|
|
+ else:
|
|
|
|
|
+ target = ws.max_row + 1
|
|
|
|
|
+ for header in CONVERSATION_HEADERS:
|
|
|
|
|
+ ws.cell(target, headers[header]).value = item.get(header, "")
|
|
|
|
|
+ existing[record_id] = target
|
|
|
|
|
+ added += 1
|
|
|
|
|
+ ws.auto_filter.ref = ws.dimensions
|
|
|
|
|
+ return added, updated
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def merge_status(existing: str, intent: str) -> str:
|
|
|
|
|
+ result = clean(existing)
|
|
|
|
|
+ for phrase in REPLY_STATUS_PHRASES:
|
|
|
|
|
+ result = result.replace(phrase, "")
|
|
|
|
|
+ result = re.sub(r"[,,;;|]+", ",", result).strip(", ")
|
|
|
|
|
+ latest = STATUS_BY_INTENT[intent]
|
|
|
|
|
+ return ",".join(dict.fromkeys([clean(part) for part in [*result.split(","), *latest.split(",")] if clean(part)]))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def reply_note(update: Dict[str, Any]) -> str:
|
|
|
|
|
+ return (
|
|
|
|
|
+ f"{NOTE_START}最新回复时间:{update['latest_reply_at']};中文摘要:{update['summary']};"
|
|
|
|
|
+ f"合作意向:{update['intent']};判断依据:{update['intent_reason']};"
|
|
|
|
|
+ f"下一步建议:{update['next_action']}{NOTE_END}"
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def replace_reply_note(existing: str, block: str) -> str:
|
|
|
|
|
+ existing = clean(existing)
|
|
|
|
|
+ pattern = re.compile(re.escape(NOTE_START) + r".*?" + re.escape(NOTE_END))
|
|
|
|
|
+ return clean(pattern.sub(block, existing)) if pattern.search(existing) else clean(existing + (" | " if existing else "") + block)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def update_facebook_rows(wb, sheet_name: str, updates: Sequence[Dict[str, Any]]) -> Tuple[int, List[str]]:
|
|
|
|
|
+ if sheet_name not in wb.sheetnames:
|
|
|
|
|
+ raise KeyError(f"Sheet not found: {sheet_name}")
|
|
|
|
|
+ ws = wb[sheet_name]
|
|
|
|
|
+ columns = header_map(ws, FACEBOOK_ALIASES)
|
|
|
|
|
+ required = {"index", "company", "link", "status", "followup", "note"}
|
|
|
|
|
+ if missing := sorted(required - set(columns)):
|
|
|
|
|
+ raise RuntimeError(f"Facebook sheet is missing columns: {', '.join(missing)}")
|
|
|
|
|
+ by_index = {clean(item.get("index")): item for item in updates if clean(item.get("index"))}
|
|
|
|
|
+ written, missing_customers, matched = 0, [], set()
|
|
|
|
|
+ for row in range(2, ws.max_row + 1):
|
|
|
|
|
+ row_index = clean(ws.cell(row, columns["index"]).value)
|
|
|
|
|
+ update = by_index.get(row_index)
|
|
|
|
|
+ if not update:
|
|
|
|
|
+ continue
|
|
|
|
|
+ if normalized_link(clean(ws.cell(row, columns["link"]).value)) != normalized_link(clean(update.get("facebook_link"))):
|
|
|
|
|
+ missing_customers.append(f"{update.get('company')}: Facebook link mismatch")
|
|
|
|
|
+ continue
|
|
|
|
|
+ ws.cell(row, columns["status"]).value = merge_status(clean(ws.cell(row, columns["status"]).value), update["intent"])
|
|
|
|
|
+ ws.cell(row, columns["followup"]).value = update["next_followup"]
|
|
|
|
|
+ existing_note = clean(ws.cell(row, columns["note"]).value)
|
|
|
|
|
+ ws.cell(row, columns["note"]).value = replace_reply_note(existing_note, reply_note(update))
|
|
|
|
|
+ matched.add(row_index)
|
|
|
|
|
+ written += 1
|
|
|
|
|
+ for update in updates:
|
|
|
|
|
+ if clean(update.get("index")) not in matched:
|
|
|
|
|
+ missing_customers.append(f"{update.get('company')}: customer row not matched")
|
|
|
|
|
+ return written, list(dict.fromkeys(missing_customers))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def scan_question_marks(wb) -> Tuple[int, int]:
|
|
|
|
|
+ cells = note_rows = 0
|
|
|
|
|
+ for ws in wb.worksheets:
|
|
|
|
|
+ note_columns = {idx for idx, cell in enumerate(ws[1], start=1) if clean(cell.value) in {"备注", "说明"}}
|
|
|
|
|
+ bad_note_rows = set()
|
|
|
|
|
+ for row in ws.iter_rows():
|
|
|
|
|
+ for cell in row:
|
|
|
|
|
+ if isinstance(cell.value, str) and QUESTION_MARK_RE.search(cell.value):
|
|
|
|
|
+ cells += 1
|
|
|
|
|
+ if cell.column in note_columns:
|
|
|
|
|
+ bad_note_rows.add(cell.row)
|
|
|
|
|
+ note_rows += len(bad_note_rows)
|
|
|
|
|
+ return cells, note_rows
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def ensure_generated_text_clean(rows: Sequence[Dict[str, Any]], updates: Sequence[Dict[str, Any]]) -> None:
|
|
|
|
|
+ for item in [*rows, *updates]:
|
|
|
|
|
+ for key, value in item.items():
|
|
|
|
|
+ if isinstance(value, str) and QUESTION_MARK_RE.search(value):
|
|
|
|
|
+ raise ValueError(f"Repeated question marks detected before write: field={key}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def run_child(command: List[str]) -> Dict[str, Any]:
|
|
|
|
|
+ completed = subprocess.run(command, text=True, encoding="utf-8", capture_output=True, check=True)
|
|
|
|
|
+ try:
|
|
|
|
|
+ return json.loads(completed.stdout)
|
|
|
|
|
+ except json.JSONDecodeError:
|
|
|
|
|
+ return {"stdout": completed.stdout.strip(), "stderr": completed.stderr.strip()}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
|
|
|
|
|
+ parser = argparse.ArgumentParser(description="Write translated Facebook conversations and intent analysis to Excel.")
|
|
|
|
|
+ parser.add_argument("--excel", default="")
|
|
|
|
|
+ parser.add_argument("--transcript", default="")
|
|
|
|
|
+ parser.add_argument("--analysis", default="")
|
|
|
|
|
+ parser.add_argument("--facebook-sheet", default=FACEBOOK_SHEET)
|
|
|
|
|
+ parser.add_argument("--conversation-sheet", default=CONVERSATION_SHEET)
|
|
|
|
|
+ parser.add_argument("--write-workbook", action="store_true")
|
|
|
|
|
+ parser.add_argument("--ensure-sheet-only", action="store_true")
|
|
|
|
|
+ parser.add_argument("--refresh-summary", action="store_true")
|
|
|
|
|
+ parser.add_argument("--refresh-dashboard", action="store_true")
|
|
|
|
|
+ parser.add_argument("--latest-dashboard-dir", default="dashboards/latest")
|
|
|
|
|
+ parser.add_argument("--run-id", default="")
|
|
|
|
|
+ parser.add_argument("--output", default="")
|
|
|
|
|
+ parser.add_argument("--no-backup", action="store_true")
|
|
|
|
|
+ return parser.parse_args(argv)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
|
|
+ args = parse_args(argv)
|
|
|
|
|
+ run_id = args.run_id or new_run_id("facebook_conversation_write")
|
|
|
|
|
+ resolved = resolve_workbook_path(args.excel, create_from_template=bool(args.write_workbook))
|
|
|
|
|
+ workbook = resolved.get("path")
|
|
|
|
|
+ if not workbook:
|
|
|
|
|
+ raise FileNotFoundError("No outreach workbook found. Pass --excel.")
|
|
|
|
|
+ workbook_path = Path(workbook)
|
|
|
|
|
+ lock_path = workbook_path.with_name("~$" + workbook_path.name)
|
|
|
|
|
+ if args.write_workbook and lock_path.exists():
|
|
|
|
|
+ raise PermissionError(f"Workbook appears to be open: {lock_path}")
|
|
|
|
|
+
|
|
|
|
|
+ rows, updates, warnings = [], [], []
|
|
|
|
|
+ if not args.ensure_sheet_only:
|
|
|
|
|
+ if not args.transcript or not args.analysis:
|
|
|
|
|
+ raise ValueError("--transcript and --analysis are required unless --ensure-sheet-only is used")
|
|
|
|
|
+ rows, updates, warnings = merge_threads(read_json(Path(args.transcript)), read_json(Path(args.analysis)))
|
|
|
|
|
+ ensure_generated_text_clean(rows, updates)
|
|
|
|
|
+
|
|
|
|
|
+ report_path = resolve_artifact_path(
|
|
|
|
|
+ args.output, kind="facebook_conversation_write",
|
|
|
|
|
+ default_name="facebook_conversation_write_report.json", run_id=run_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ report: Dict[str, Any] = {
|
|
|
|
|
+ "schema_version": "4.26",
|
|
|
|
|
+ "run_id": run_id,
|
|
|
|
|
+ "workbook": str(workbook_path),
|
|
|
|
|
+ "dry_run": not args.write_workbook,
|
|
|
|
|
+ "conversation_rows_ready": len(rows),
|
|
|
|
|
+ "customer_updates_ready": len(updates),
|
|
|
|
|
+ "intent_counts": {intent: sum(1 for item in updates if item.get("intent") == intent) for intent in INTENTS},
|
|
|
|
|
+ "high_intent_customers": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "customer": item["company"], "intent": item["intent"], "summary": item["summary"],
|
|
|
|
|
+ "next_action": item["next_action"], "next_followup": item["next_followup"],
|
|
|
|
|
+ }
|
|
|
|
|
+ for item in updates if item.get("intent") in {"明确有意向", "潜在意向"}
|
|
|
|
|
+ ],
|
|
|
|
|
+ "warnings": warnings,
|
|
|
|
|
+ }
|
|
|
|
|
+ if not args.write_workbook:
|
|
|
|
|
+ write_json(report_path, report)
|
|
|
|
|
+ print(json.dumps({"report": str(report_path), **report}, ensure_ascii=False, indent=2))
|
|
|
|
|
+ return 0
|
|
|
|
|
+
|
|
|
|
|
+ backup_path = None if args.no_backup else create_backup_once(
|
|
|
|
|
+ workbook_path, purpose="facebook_conversations", run_id=run_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ wb = load_workbook(workbook_path)
|
|
|
|
|
+ before_question_cells, before_question_notes = scan_question_marks(wb)
|
|
|
|
|
+ if before_question_cells:
|
|
|
|
|
+ raise ValueError(
|
|
|
|
|
+ f"Workbook already contains repeated-question-mark corruption: cells={before_question_cells}, "
|
|
|
|
|
+ f"note_rows={before_question_notes}"
|
|
|
|
|
+ )
|
|
|
|
|
+ conversation_ws = ensure_conversation_sheet(wb, args.conversation_sheet)
|
|
|
|
|
+ added, conversation_updated = append_or_update_conversations(conversation_ws, rows)
|
|
|
|
|
+ customer_rows_updated, missing_customers = (
|
|
|
|
|
+ update_facebook_rows(wb, args.facebook_sheet, updates) if updates else (0, [])
|
|
|
|
|
+ )
|
|
|
|
|
+ if scan_question_marks(wb)[0]:
|
|
|
|
|
+ raise ValueError("Repeated question marks detected before save")
|
|
|
|
|
+ wb.save(workbook_path)
|
|
|
|
|
+
|
|
|
|
|
+ summary_report, dashboard_report = {}, {}
|
|
|
|
|
+ refresh_summary = bool(args.refresh_summary or args.refresh_dashboard)
|
|
|
|
|
+ if refresh_summary:
|
|
|
|
|
+ summary_report = run_child([
|
|
|
|
|
+ sys.executable, str(SKILL_ROOT / "scripts" / "common" / "build_customer_summary.py"),
|
|
|
|
|
+ "--excel", str(workbook_path), "--write-summary", "--no-backup", "--run-id", run_id,
|
|
|
|
|
+ ])
|
|
|
|
|
+ if args.refresh_dashboard:
|
|
|
|
|
+ dashboard_report = run_child([
|
|
|
|
|
+ sys.executable, str(SKILL_ROOT / "scripts" / "dashboard" / "build_dashboard.py"),
|
|
|
|
|
+ "--excel", str(workbook_path), "--run-id", run_id,
|
|
|
|
|
+ "--latest-dir", args.latest_dashboard_dir,
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ reopened = load_workbook(workbook_path, data_only=True, read_only=True)
|
|
|
|
|
+ question_mark_cells, question_mark_note_rows = scan_question_marks(reopened)
|
|
|
|
|
+ reopened.close()
|
|
|
|
|
+ report.update({
|
|
|
|
|
+ "dry_run": False,
|
|
|
|
|
+ "backup": str(backup_path) if backup_path else "",
|
|
|
|
|
+ "conversation_rows_added": added,
|
|
|
|
|
+ "conversation_rows_updated": conversation_updated,
|
|
|
|
|
+ "customer_rows_updated": customer_rows_updated,
|
|
|
|
|
+ "missing_customers": missing_customers,
|
|
|
|
|
+ "question_mark_cells": question_mark_cells,
|
|
|
|
|
+ "question_mark_note_rows": question_mark_note_rows,
|
|
|
|
|
+ "summary_refreshed": refresh_summary,
|
|
|
|
|
+ "summary_report": summary_report,
|
|
|
|
|
+ "dashboard_refreshed": bool(args.refresh_dashboard),
|
|
|
|
|
+ "dashboard_report": dashboard_report,
|
|
|
|
|
+ })
|
|
|
|
|
+ config_path = project_root(workbook_path.parent) / "feishu_sync_config.json"
|
|
|
|
|
+ if config_path.exists():
|
|
|
|
|
+ try:
|
|
|
|
|
+ config = read_json(config_path)
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ config = {"enabled": False, "config_error": str(exc)}
|
|
|
|
|
+ report["feishu_sync"] = {
|
|
|
|
|
+ "config_found": True,
|
|
|
|
|
+ "enabled": bool(config.get("enabled")),
|
|
|
|
|
+ "sync_required": bool(config.get("enabled")),
|
|
|
|
|
+ "conversation_sheet_name": config.get("conversation_sheet_name", args.conversation_sheet),
|
|
|
|
|
+ "sync_conversation_sheet": config.get("sync_conversation_sheet", True),
|
|
|
|
|
+ "agent_action": "Use lark-sheets after local write; Python never stores Feishu credentials.",
|
|
|
|
|
+ }
|
|
|
|
|
+ else:
|
|
|
|
|
+ report["feishu_sync"] = {
|
|
|
|
|
+ "config_found": False,
|
|
|
|
|
+ "enabled": False,
|
|
|
|
|
+ "sync_required": False,
|
|
|
|
|
+ "message": "本地建联表已更新;未发现 feishu_sync_config.json,飞书同步未执行。",
|
|
|
|
|
+ }
|
|
|
|
|
+ write_json(report_path, report)
|
|
|
|
|
+ print(json.dumps({"report": str(report_path), **report}, ensure_ascii=False, indent=2))
|
|
|
|
|
+ return 3 if question_mark_cells or missing_customers else 0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+if __name__ == "__main__":
|
|
|
|
|
+ raise SystemExit(main())
|