#!/usr/bin/env python3 import argparse import ast import datetime as dt import json import re import subprocess import sys from collections import defaultdict from typing import Dict, List, Tuple BASE_BIN = "/usr/local/bin/customer-service-analysis.base" GREETING_KEYWORDS = ("欢迎", "您好", "亲,", "亲,", "亲~") EMPATHY_KEYWORDS = ("抱歉", "不好意思", "理解您", "理解你的", "辛苦了") COMMITMENT_KEYWORDS = ("马上", "尽快", "给您处理", "为您处理", "帮您处理", "我来帮您") CLOSING_KEYWORDS = ("感谢", "祝您", "辛苦", "生活愉快", "欢迎下次") RISK_KEYWORDS = ("不知道", "没办法", "无法处理", "不清楚", "等不了", "太慢") AFTERSALE_ROLE_KEYWORDS = ( "退货", "退款", "售后", "安装", "工单", "物流", "补发", "换货", "维修", "催退款", "催取件", "拒收", "赔付", ) def _parse_date(value: str) -> dt.date: try: return dt.datetime.strptime(value, "%Y-%m-%d").date() except ValueError as exc: raise argparse.ArgumentTypeError(f"invalid date '{value}', expected YYYY-MM-DD") from exc def _sql_escape(value: str) -> str: return value.replace("'", "''") def _fmt(v): if v is None: return "" if isinstance(v, float): return round(v, 2) return v def _safe_pct(num: float, den: float): if den <= 0: return None return round((num / den) * 100.0, 2) def _score_grade(score: float) -> str: if score >= 90: return "S" if score >= 80: return "A" if score >= 70: return "B" if score >= 60: return "C" return "D" def _contains_any(text: str, keywords: Tuple[str, ...]) -> bool: return any(k in text for k in keywords) def _split_result_sets(output_text: str) -> Tuple[Dict[int, List[str]], int]: blocks: Dict[int, List[str]] = {} current_idx = None current_lines: List[str] = [] total_sets = 0 for line in (output_text or "").splitlines(): if line.startswith("TOTAL_RESULT_SETS:"): try: total_sets = int(line.split(":", 1)[1].strip()) except Exception: total_sets = 0 continue if line.startswith("RESULT_SET "): if current_idx is not None: blocks[current_idx] = current_lines try: current_idx = int(line.split()[1]) except Exception: current_idx = None current_lines = [line] if current_idx is not None else [] continue if current_idx is not None: current_lines.append(line) if current_idx is not None: blocks[current_idx] = current_lines return blocks, total_sets def _extract_json_rows(output: str) -> list: rows = [] for line in output.splitlines(): line = line.strip() if not line.startswith("{") or not line.endswith("}"): continue try: rows.append(json.loads(line)) except json.JSONDecodeError: continue return rows def _run_company_query(sql_text: str, limit: int = 100000) -> List[Dict]: proc = subprocess.run( ["company-db", "query", "--limit", str(limit), "--sql", sql_text], capture_output=True, text=True, ) if proc.returncode != 0: err = (proc.stderr or proc.stdout or "company-db failed").strip() raise RuntimeError(err) return _extract_json_rows(proc.stdout or "") def _print_result_set(index: int, columns: List[str], rows: List[Dict]) -> None: print(f"RESULT_SET {index}") print("COLUMNS: " + ", ".join(columns)) print(f"ROWS: {len(rows)}") for row in rows: obj = {c: row.get(c) for c in columns} print(json.dumps(obj, ensure_ascii=False)) def _clean_user_label(label: str) -> str: return (label or "").replace("\ue7f6", "").strip() def _extract_agent_name(user_label: str) -> str | None: label = _clean_user_label(user_label) if "-->" in label: return None if ":" not in label: return None prefix, name = label.rsplit(":", 1) prefix = prefix.strip() name = name.strip() if not prefix or not name: return None if len(name) > 40: return None if "店" not in prefix and "客服" not in prefix: return None return name def _parse_dialog_entries(content_text: str) -> list[tuple[str, str]]: text = (content_text or "").strip() if not text or text == "[]": return [] entries: list[tuple[str, str]] = [] try: data = ast.literal_eval(text) if isinstance(data, list): for item in data: if isinstance(item, dict): entries.append((str(item.get("user", "")), str(item.get("message", "")))) if entries: return entries except Exception: pass pattern = re.compile(r"'user':\s*'([^']*)'.*?'message':\s*'([^']*)'", re.S) for user, message in pattern.findall(text): entries.append((user, message)) return entries def _classify_role(main_reason: str, second_reason: str, third_reason: str) -> str: text = f"{main_reason or ''}|{second_reason or ''}|{third_reason or ''}" if _contains_any(text, AFTERSALE_ROLE_KEYWORDS): return "售后" return "售前" def _timeliness_score(avg_submit_to_kf_hours): if avg_submit_to_kf_hours is None: return 60.0 h = float(avg_submit_to_kf_hours) if h <= 4: return 100.0 if h <= 12: return 90.0 if h <= 24: return 80.0 if h <= 48: return 70.0 if h <= 72: return 60.0 return 40.0 def _to_float(value): if value is None: return None if isinstance(value, (int, float)): return float(value) text = str(value).strip().replace(',', '') if text == '' or text.lower() in ('none', 'null', 'nan'): return None if text.endswith('%'): text = text[:-1] try: return float(text) except Exception: return None def _to_int(value): v = _to_float(value) if v is None: return 0 return int(round(v)) def _to_pct(value): v = _to_float(value) if v is None: return None if v <= 1.2: v = v * 100.0 return max(0.0, min(100.0, v)) def _normalize_shop_name(name: str) -> str: t = (name or '').strip().lower() t = t.replace(' ', '').replace(' ', '') for p in ('天猫-', '淘宝-', '京东-', '京喜-', '拼多多-', '抖音-', '阿里巴巴-', '唯品会-', '得物-', '快手-'): if t.startswith(p): t = t[len(p):] t = t.replace('-', '').replace('_', '') return t def _normalize_agent_name(name: str) -> str: t = (name or '').strip().lower() t = t.replace(' ', '').replace(' ', '') t = re.sub(r'^\d+号', '', t) t = re.sub(r'(师傅|客服|专员|老师)$', '', t) return t def _normalize_role_text(value: str) -> str: t = (value or '').strip() if '售后' in t: return '售后' if '售前' in t: return '售前' return '' def _expand_agent_aliases(name: str) -> List[str]: raw = (name or '').strip() if not raw: return [] raw_candidates = {raw} for sep in (':', ':'): if sep in raw: parts = [x.strip() for x in raw.split(sep) if x and x.strip()] if parts: raw_candidates.add(parts[-1]) norm_set = set() for item in raw_candidates: cleaned = re.sub(r'[((].*?[))]', '', item).strip() if not cleaned: continue normalized = _normalize_agent_name(cleaned) if not normalized: continue norm_set.add(normalized) norm_set.add(re.sub(r'\d+$', '', normalized)) no_prefix = re.sub(r'^搭配师', '', normalized) norm_set.add(no_prefix) norm_set.add(re.sub(r'\d+$', '', no_prefix)) norm_set.discard('') return sorted(norm_set) def _parse_month_key(month_text: str) -> int: if not month_text: return 0 s = str(month_text) m = re.search(r'(20\d{2})-(\d{1,2})', s) if m: y = int(m.group(1)) mm = int(m.group(2)) if 1 <= mm <= 12: return y * 100 + mm m = re.search(r'(\d{2,4})年(\d{1,2})月', s) if m: y = int(m.group(1)) if y < 100: y += 2000 mm = int(m.group(2)) if 1 <= mm <= 12: return y * 100 + mm return 0 def _build_external_conversion_sql(shop: str) -> str: shop_escaped = _sql_escape(shop.strip()) return f""" USE [JSDataMiddlePlatform]; SET NOCOUNT ON; DECLARE @shop nvarchar(100) = N'{shop_escaped}'; SELECT [月份] AS month_text, [店铺名称] AS shop_name, [客服昵称] AS agent_name, [询单人数] AS inquiry_count_text, [当日或次日付款人数] AS paid_next_count_text, [最终付款人数] AS paid_final_count_text, [询单->次日付款转化率] AS conv_next_text, [询单->最终付款转化率] AS conv_final_text FROM dbo.cs_conversion_rate WITH (NOLOCK) WHERE (@shop = N'' OR [店铺名称] LIKE N'%' + @shop + N'%'); """ def _build_external_satisfaction_sql(shop: str) -> str: shop_escaped = _sql_escape(shop.strip()) return f""" USE [JSDataMiddlePlatform]; SET NOCOUNT ON; DECLARE @shop nvarchar(100) = N'{shop_escaped}'; SELECT [月份] AS month_text, [店铺名称] AS shop_name, [客服昵称] AS agent_name, [聊天人次(原咨询人次)] AS chat_count_text, [评价展示量] AS eval_show_text, [评价参与量] AS eval_join_text, [客户满意率] AS customer_satisfaction_text, [服务满意度评分] AS service_score_text FROM dbo.cs_satisfaction WITH (NOLOCK) WHERE (@shop = N'' OR [店铺名称] LIKE N'%' + @shop + N'%'); """ def _build_external_roster_sql(shop: str) -> str: shop_escaped = _sql_escape(shop.strip()) return f""" USE [JSDataMiddlePlatform]; SET NOCOUNT ON; DECLARE @shop nvarchar(100) = N'{shop_escaped}'; SELECT [店铺] AS shop_name, [岗位] AS position_role, [账号名] AS account_name, [赤兔展示名] AS display_name, [花名] AS nick_name, [姓名] AS real_name FROM dbo.cs_roster WITH (NOLOCK) WHERE (@shop = N'' OR [店铺] LIKE N'%' + @shop + N'%'); """ def _roster_entry_score(entry: Dict) -> Tuple[int, int]: return ( 1 if entry.get('position_role') else 0, len(entry.get('alias_norms') or []), ) def _load_cs_roster(shop: str) -> Dict: rows = _run_company_query(_build_external_roster_sql(shop), limit=100000) by_shop_alias: Dict[Tuple[str, str], Dict] = {} by_alias: Dict[str, Dict] = {} for r in rows: shop_name = str(r.get('shop_name') or '') position_role = _normalize_role_text(str(r.get('position_role') or '')) account_name = str(r.get('account_name') or '').strip() display_name = str(r.get('display_name') or '').strip() nick_name = str(r.get('nick_name') or '').strip() real_name = str(r.get('real_name') or '').strip() alias_norms = set() for raw in (account_name, display_name, nick_name, real_name): for alias in _expand_agent_aliases(raw): alias_norms.add(alias) if not alias_norms: continue entry = { 'shop_name': shop_name, 'shop_norm': _normalize_shop_name(shop_name), 'position_role': position_role, 'account_name': account_name, 'display_name': display_name, 'nick_name': nick_name, 'real_name': real_name, 'alias_norms': sorted(alias_norms), } for alias in alias_norms: k = (entry['shop_norm'], alias) cur = by_shop_alias.get(k) if cur is None or _roster_entry_score(entry) > _roster_entry_score(cur): by_shop_alias[k] = entry cur_alias = by_alias.get(alias) if cur_alias is None or _roster_entry_score(entry) > _roster_entry_score(cur_alias): by_alias[alias] = entry return { 'by_shop_alias': by_shop_alias, 'by_alias': by_alias, } def _pick_roster_entry(roster_index: Dict, shop_name: str, agent_name: str) -> Dict: if not roster_index: return {} ns = _normalize_shop_name(shop_name) alias_list = _expand_agent_aliases(agent_name) by_shop_alias = roster_index.get('by_shop_alias') or {} by_alias = roster_index.get('by_alias') or {} for alias in alias_list: hit = by_shop_alias.get((ns, alias)) if hit: return hit fuzzy_candidates = [] for alias in alias_list: for (shop_norm, alias_norm), hit in by_shop_alias.items(): if alias_norm != alias: continue if shop_norm == ns or (shop_norm and ns and (shop_norm in ns or ns in shop_norm)): fuzzy_candidates.append(hit) if fuzzy_candidates: fuzzy_candidates.sort(key=_roster_entry_score, reverse=True) return fuzzy_candidates[0] for alias in alias_list: hit = by_alias.get(alias) if hit: return hit return {} def _load_external_cs_metrics(shop: str) -> Dict[Tuple[str, str], Dict]: conv_rows = _run_company_query(_build_external_conversion_sql(shop), limit=200000) sat_rows = _run_company_query(_build_external_satisfaction_sql(shop), limit=200000) conv_best: Dict[Tuple[str, str], Dict] = {} sat_best: Dict[Tuple[str, str], Dict] = {} for r in conv_rows: key = (_normalize_shop_name(str(r.get('shop_name') or '')), _normalize_agent_name(str(r.get('agent_name') or ''))) if not key[0] or not key[1]: continue mk = _parse_month_key(r.get('month_text')) cur = conv_best.get(key) inquiry_count = _to_int(r.get('inquiry_count_text')) if cur is None or mk > cur['month_key'] or (mk == cur['month_key'] and inquiry_count > cur.get('inquiry_count', 0)): conv_best[key] = { 'month_key': mk, 'month_text': str(r.get('month_text') or ''), 'shop_name': str(r.get('shop_name') or ''), 'agent_name': str(r.get('agent_name') or ''), 'inquiry_count': inquiry_count, 'paid_next_count': _to_int(r.get('paid_next_count_text')), 'paid_final_count': _to_int(r.get('paid_final_count_text')), 'conv_next_pct': _to_pct(r.get('conv_next_text')), 'conv_final_pct': _to_pct(r.get('conv_final_text')), } for r in sat_rows: key = (_normalize_shop_name(str(r.get('shop_name') or '')), _normalize_agent_name(str(r.get('agent_name') or ''))) if not key[0] or not key[1]: continue mk = _parse_month_key(r.get('month_text')) cur = sat_best.get(key) eval_join = _to_int(r.get('eval_join_text')) if cur is None or mk > cur['month_key'] or (mk == cur['month_key'] and eval_join > cur.get('eval_join_count', 0)): sat_best[key] = { 'month_key': mk, 'month_text': str(r.get('month_text') or ''), 'shop_name': str(r.get('shop_name') or ''), 'agent_name': str(r.get('agent_name') or ''), 'chat_count': _to_int(r.get('chat_count_text')), 'eval_show_count': _to_int(r.get('eval_show_text')), 'eval_join_count': eval_join, 'customer_satisfaction_pct': _to_pct(r.get('customer_satisfaction_text')), 'service_score_5': _to_float(r.get('service_score_text')), } out: Dict[Tuple[str, str], Dict] = {} all_keys = set(conv_best.keys()) | set(sat_best.keys()) for k in all_keys: c = conv_best.get(k, {}) s = sat_best.get(k, {}) out[k] = { 'shop_norm': k[0], 'agent_norm': k[1], 'month_key': max(c.get('month_key', 0), s.get('month_key', 0)), 'month_text': c.get('month_text') or s.get('month_text') or '', 'shop_name': c.get('shop_name') or s.get('shop_name') or '', 'agent_name': c.get('agent_name') or s.get('agent_name') or '', 'inquiry_count': c.get('inquiry_count', 0), 'paid_next_count': c.get('paid_next_count', 0), 'paid_final_count': c.get('paid_final_count', 0), 'conv_next_pct': c.get('conv_next_pct'), 'conv_final_pct': c.get('conv_final_pct'), 'chat_count': s.get('chat_count', 0), 'eval_show_count': s.get('eval_show_count', 0), 'eval_join_count': s.get('eval_join_count', 0), 'customer_satisfaction_pct': s.get('customer_satisfaction_pct'), 'service_score_5': s.get('service_score_5'), } return out def _pick_external_metric( external: Dict[Tuple[str, str], Dict], shop_name: str, agent_name: str, agent_alias_norms=None, ) -> Dict: ns = _normalize_shop_name(shop_name) alias_list = [] if agent_alias_norms: alias_list.extend([str(x) for x in agent_alias_norms if x]) na = _normalize_agent_name(agent_name) if na: alias_list.append(na) seen = set() dedup_aliases = [] for alias in alias_list: if alias in seen: continue seen.add(alias) dedup_aliases.append(alias) for alias in dedup_aliases: direct = external.get((ns, alias)) if direct: return direct candidates = [ v for (s, a), v in external.items() if a in seen and (s == ns or (s and ns and (s in ns or ns in s))) ] if not candidates: candidates = [v for (s, a), v in external.items() if a in seen] if not candidates: return {} candidates.sort(key=lambda x: (x.get('month_key', 0), x.get('inquiry_count', 0), x.get('eval_join_count', 0)), reverse=True) return candidates[0] def _build_agent_raw_sql(start_date: dt.date, end_date: dt.date, shop: str, max_rows: int) -> str: shop_escaped = _sql_escape(shop.strip()) return f""" USE [JSDataMiddlePlatform]; SET NOCOUNT ON; DECLARE @start_time datetime2 = CAST('{start_date.isoformat()}' AS datetime2); DECLARE @end_time datetime2 = DATEADD(day, 1, CAST('{end_date.isoformat()}' AS datetime2)); DECLARE @shop nvarchar(100) = N'{shop_escaped}'; ;WITH base AS ( SELECT c.id, NULLIF(c.orderNo, '') AS order_no, ISNULL(NULLIF(c.shopName, ''), N'未知店铺') AS shop_name, ISNULL(NULLIF(LTRIM(RTRIM(c.mainReason)), ''), N'未标注') AS main_reason, ISNULL(NULLIF(LTRIM(RTRIM(c.secondReason)), ''), N'未标注') AS second_reason, ISNULL(NULLIF(LTRIM(RTRIM(c.thirdReason)), ''), N'未标注') AS third_reason, CAST(c.content AS nvarchar(max)) AS content_text, c.createdAt, ROW_NUMBER() OVER (ORDER BY c.createdAt DESC, c.id DESC) AS rn, COUNT(1) OVER () AS total_rows FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) WHERE c.createdAt >= @start_time AND c.createdAt < @end_time AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%') ) SELECT TOP ({max_rows}) id, order_no, shop_name, main_reason, second_reason, third_reason, content_text, total_rows FROM base ORDER BY rn ASC; """ def _build_order_snapshot_sql(start_date: dt.date, end_date: dt.date, shop: str) -> str: shop_escaped = _sql_escape(shop.strip()) return f""" USE [JSDataMiddlePlatform]; SET NOCOUNT ON; DECLARE @start_time datetime2 = CAST('{start_date.isoformat()}' AS datetime2); DECLARE @end_time datetime2 = DATEADD(day, 1, CAST('{end_date.isoformat()}' AS datetime2)); DECLARE @shop nvarchar(100) = N'{shop_escaped}'; ;WITH chat_orders AS ( SELECT DISTINCT NULLIF(c.orderNo, '') AS order_no FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) WHERE c.createdAt >= @start_time AND c.createdAt < @end_time AND NULLIF(c.orderNo, '') IS NOT NULL AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%') ) SELECT co.order_no, CASE WHEN o.pay_time IS NOT NULL THEN 1 ELSE 0 END AS is_paid, CASE WHEN o.send_time IS NOT NULL THEN 1 ELSE 0 END AS is_shipped, CASE WHEN t.OrderNum IS NOT NULL THEN 1 ELSE 0 END AS has_ticket, t.StatusId AS ticket_status, DATEDIFF(minute, t.SubmitTime, t.kfshTime) AS submit_to_kf_minutes, DATEDIFF(minute, t.kfshTime, t.cwshTime) AS kf_to_cw_minutes FROM chat_orders AS co OUTER APPLY ( SELECT TOP 1 x.pay_time, x.send_time FROM dbo.jst_order_express_details AS x WITH (NOLOCK) WHERE x.so_id = co.order_no ORDER BY x.updatedAt DESC, x.id DESC ) AS o OUTER APPLY ( SELECT TOP 1 i.OrderNum, i.StatusId, i.SubmitTime, i.kfshTime, i.cwshTime FROM dbo.fys_Ticketing_install AS i WITH (NOLOCK) WHERE i.OrderNum = co.order_no ORDER BY i.SubmitTime DESC, i.Id DESC ) AS t; """ def _compute_role_score_rows( start_date: dt.date, end_date: dt.date, shop: str, limit: int, agent_max_rows: int, ) -> List[Dict]: chat_rows = _run_company_query(_build_agent_raw_sql(start_date, end_date, shop, agent_max_rows), limit=agent_max_rows) if not chat_rows: return [] total_rows = int(chat_rows[0].get("total_rows") or len(chat_rows)) sampled_rows = len(chat_rows) sample_scope = f"full_{sampled_rows}/{total_rows}" if total_rows > sampled_rows: sample_scope = f"sampled_recent_{sampled_rows}/{total_rows}" roster_index = _load_cs_roster(shop) stats = defaultdict( lambda: { "session_set": set(), "order_set": set(), "primary_session_count": 0, "message_count": 0, "greeting_hits": 0, "empathy_hits": 0, "commitment_hits": 0, "closing_hits": 0, "risk_hits": 0, "no_match_session_count": 0, "missing_session_count": 0, "role_source_roster_count": 0, "role_source_reason_count": 0, "roster_position_role": "", "roster_shop_name": "", "roster_display_name": "", "alias_norms": set(), } ) for row in chat_rows: dialogs = _parse_dialog_entries(row.get("content_text") or "") if not dialogs: continue main_reason = str(row.get("main_reason") or "") second_reason = str(row.get("second_reason") or "") third_reason = str(row.get("third_reason") or "") role_reason = _classify_role(main_reason, second_reason, third_reason) shop_name = str(row.get("shop_name") or "未知店铺") order_no = (row.get("order_no") or "").strip() session_key = order_no or f"id:{row.get('id')}" agent_turns = defaultdict(int) agent_features = defaultdict(lambda: {"g": 0, "e": 0, "c": 0, "cl": 0, "r": 0, "m": 0}) for user_label, message in dialogs: agent_name = _extract_agent_name(user_label) if not agent_name: continue agent_turns[agent_name] += 1 msg = (message or "").strip() f = agent_features[agent_name] f["m"] += 1 if _contains_any(msg, GREETING_KEYWORDS): f["g"] += 1 if _contains_any(msg, EMPATHY_KEYWORDS): f["e"] += 1 if _contains_any(msg, COMMITMENT_KEYWORDS): f["c"] += 1 if _contains_any(msg, CLOSING_KEYWORDS): f["cl"] += 1 if _contains_any(msg, RISK_KEYWORDS): f["r"] += 1 if not agent_turns: continue primary_agent = max(agent_turns.items(), key=lambda x: (x[1], x[0]))[0] roster_entry = _pick_roster_entry(roster_index, shop_name, primary_agent) roster_role = _normalize_role_text(str(roster_entry.get("position_role") or "")) if roster_entry else "" role = roster_role or role_reason key = (shop_name, primary_agent, role) m = stats[key] m["session_set"].add(session_key) if order_no: m["order_set"].add(order_no) m["primary_session_count"] += 1 if roster_role: m["role_source_roster_count"] += 1 m["roster_position_role"] = roster_role else: m["role_source_reason_count"] += 1 if roster_entry: if roster_entry.get("shop_name"): m["roster_shop_name"] = str(roster_entry.get("shop_name")) if roster_entry.get("display_name"): m["roster_display_name"] = str(roster_entry.get("display_name")) for alias in roster_entry.get("alias_norms") or []: m["alias_norms"].add(alias) ff = agent_features[primary_agent] m["message_count"] += ff["m"] m["greeting_hits"] += ff["g"] m["empathy_hits"] += ff["e"] m["commitment_hits"] += ff["c"] m["closing_hits"] += ff["cl"] m["risk_hits"] += ff["r"] if main_reason == "无对应分类" or second_reason == "无对应分类": m["no_match_session_count"] += 1 if main_reason == "未标注" or second_reason == "未标注": m["missing_session_count"] += 1 order_snapshot_rows = _run_company_query(_build_order_snapshot_sql(start_date, end_date, shop), limit=200000) order_map: Dict[str, Dict] = {} for r in order_snapshot_rows: order_no = (r.get("order_no") or "").strip() if not order_no: continue order_map[order_no] = { "is_paid": int(r.get("is_paid") or 0), "is_shipped": int(r.get("is_shipped") or 0), "has_ticket": int(r.get("has_ticket") or 0), "ticket_status": r.get("ticket_status"), "submit_to_kf_minutes": r.get("submit_to_kf_minutes"), } external_metrics = _load_external_cs_metrics(shop) result_rows: List[Dict] = [] for (shop_name, agent_name, role), m in stats.items(): session_count = len(m["session_set"]) message_count = m["message_count"] if session_count <= 0 or message_count <= 0: continue greeting_rate = m["greeting_hits"] / message_count empathy_rate = m["empathy_hits"] / message_count commitment_rate = m["commitment_hits"] / message_count closing_rate = m["closing_hits"] / message_count risk_rate = m["risk_hits"] / message_count no_match_rate = m["no_match_session_count"] / session_count missing_rate = m["missing_session_count"] / session_count resp_score = max(0.0, min(100.0, 100.0 * (0.70 * commitment_rate + 0.30 * (1.0 - risk_rate)))) sat_chat_score = max(0.0, min(100.0, 100.0 * (0.40 * empathy_rate + 0.35 * closing_rate + 0.25 * greeting_rate))) risk_ctrl_score = max(0.0, min(100.0, 100.0 * (1.0 - (0.75 * no_match_rate + 0.25 * missing_rate)))) order_set = m["order_set"] inquiry_order_count = len(order_set) converted_order_count = 0 ticket_linked_order_count = 0 ticket_resolved_order_count = 0 submit_to_kf_hours_list: List[float] = [] for order_no in order_set: snap = order_map.get(order_no) if not snap: continue if snap.get("is_paid") == 1 or snap.get("is_shipped") == 1: converted_order_count += 1 if snap.get("has_ticket") == 1: ticket_linked_order_count += 1 if str(snap.get("ticket_status")) == "2": ticket_resolved_order_count += 1 mins = snap.get("submit_to_kf_minutes") try: mins = float(mins) except Exception: mins = None if mins is not None and 0 <= mins <= 43200: submit_to_kf_hours_list.append(mins / 60.0) inquiry_conversion_rate_pct = _safe_pct(converted_order_count, inquiry_order_count) ticket_resolve_rate_pct = _safe_pct(ticket_resolved_order_count, ticket_linked_order_count) avg_submit_to_kf_hours = ( round(sum(submit_to_kf_hours_list) / len(submit_to_kf_hours_list), 2) if submit_to_kf_hours_list else None ) alias_norms = set(m.get("alias_norms") or set()) for alias in _expand_agent_aliases(agent_name): alias_norms.add(alias) ext = _pick_external_metric(external_metrics, shop_name, agent_name, alias_norms) ext_month_text = ext.get("month_text") if ext else "" ext_shop_name = ext.get("shop_name") if ext else "" ext_inquiry_count = ext.get("inquiry_count", 0) if ext else 0 ext_paid_next_count = ext.get("paid_next_count", 0) if ext else 0 ext_paid_final_count = ext.get("paid_final_count", 0) if ext else 0 ext_conv_next_pct = ext.get("conv_next_pct") if ext else None ext_conv_final_pct = ext.get("conv_final_pct") if ext else None ext_chat_count = ext.get("chat_count", 0) if ext else 0 ext_eval_show_count = ext.get("eval_show_count", 0) if ext else 0 ext_eval_join_count = ext.get("eval_join_count", 0) if ext else 0 ext_customer_satisfaction_pct = ext.get("customer_satisfaction_pct") if ext else None ext_service_score_5 = ext.get("service_score_5") if ext else None sat_external_score = None if ext_customer_satisfaction_pct is not None: sat_external_score = max(0.0, min(100.0, float(ext_customer_satisfaction_pct))) elif ext_service_score_5 is not None: sat_external_score = max(0.0, min(100.0, float(ext_service_score_5) * 20.0)) if sat_external_score is None: sat_final_score = sat_chat_score satisfaction_source = "chat_lexicon" else: sat_final_score = 0.6 * sat_external_score + 0.4 * sat_chat_score satisfaction_source = "cs_satisfaction+chat_lexicon" conversion_score = inquiry_conversion_rate_pct if inquiry_conversion_rate_pct is not None else 0.0 conversion_source = "order_snapshot" if ext_conv_final_pct is not None: conversion_score = float(ext_conv_final_pct) conversion_source = "cs_conversion_rate" resolution_score = ticket_resolve_rate_pct if ticket_resolve_rate_pct is not None else 60.0 timeliness_score = _timeliness_score(avg_submit_to_kf_hours) role_source = "cs_roster" if m["role_source_roster_count"] > 0 else "reason_inference" roster_position_role = m["roster_position_role"] or "" roster_shop_name = m["roster_shop_name"] or "" roster_display_name = m["roster_display_name"] or "" if role == "售前": role_score = ( 0.20 * resp_score + 0.25 * sat_final_score + 0.40 * conversion_score + 0.15 * risk_ctrl_score ) rule_note = "售前评分=响应20%+满意25%+询单转化40%+风险控制15%" else: role_score = ( 0.15 * resp_score + 0.20 * sat_final_score + 0.35 * resolution_score + 0.20 * timeliness_score + 0.10 * risk_ctrl_score ) rule_note = "售后评分=响应15%+满意20%+工单闭环35%+处理时效20%+风险控制10%" reliable = session_count >= 10 and message_count >= 20 result_rows.append( { "shop_name": shop_name, "agent_name": agent_name, "position_role": role, "role_source": role_source, "roster_position_role": roster_position_role, "roster_shop_name": roster_shop_name, "roster_display_name": roster_display_name, "session_count": session_count, "primary_session_count": m["primary_session_count"], "message_count": message_count, "inquiry_order_count": inquiry_order_count, "converted_order_count": converted_order_count, "inquiry_conversion_rate_pct": _fmt(inquiry_conversion_rate_pct), "ticket_linked_order_count": ticket_linked_order_count, "ticket_resolved_order_count": ticket_resolved_order_count, "ticket_resolve_rate_pct": _fmt(ticket_resolve_rate_pct), "avg_submit_to_kf_hours": _fmt(avg_submit_to_kf_hours), "external_month": ext_month_text, "external_shop_name": ext_shop_name, "external_inquiry_count": ext_inquiry_count, "external_paid_next_count": ext_paid_next_count, "external_paid_final_count": ext_paid_final_count, "external_conv_next_pct": _fmt(ext_conv_next_pct), "external_conv_final_pct": _fmt(ext_conv_final_pct), "external_chat_count": ext_chat_count, "external_eval_show_count": ext_eval_show_count, "external_eval_join_count": ext_eval_join_count, "external_customer_satisfaction_pct": _fmt(ext_customer_satisfaction_pct), "external_service_score_5": _fmt(ext_service_score_5), "resp_ability_score": round(resp_score, 2), "satisfaction_chat_score": round(sat_chat_score, 2), "satisfaction_external_score": _fmt(sat_external_score), "satisfaction_score": round(sat_final_score, 2), "conversion_score": round(conversion_score, 2), "conversion_source": conversion_source, "resolution_score": round(resolution_score, 2), "timeliness_score": round(timeliness_score, 2), "risk_control_score": round(risk_ctrl_score, 2), "role_capability_score": round(role_score, 2), "role_grade": _score_grade(role_score), "is_reliable_sample": "是" if reliable else "否", "sample_scope": sample_scope, "satisfaction_source": satisfaction_source, "score_rule_note": rule_note, } ) reliable_rows = [r for r in result_rows if r["is_reliable_sample"] == "是"] ranked_rows = reliable_rows if reliable_rows else result_rows ranked_rows.sort( key=lambda x: ( x["role_capability_score"], x["session_count"], x["message_count"], x["agent_name"], ), reverse=True, ) return ranked_rows[:limit] def _build_ticket_summary_sql(start_date: dt.date, end_date: dt.date, shop: str) -> str: shop_escaped = _sql_escape(shop.strip()) return f""" USE [JSDataMiddlePlatform]; SET NOCOUNT ON; DECLARE @start_time datetime2 = CAST('{start_date.isoformat()}' AS datetime2); DECLARE @end_time datetime2 = DATEADD(day, 1, CAST('{end_date.isoformat()}' AS datetime2)); DECLARE @shop nvarchar(100) = N'{shop_escaped}'; ;WITH chat_orders AS ( SELECT DISTINCT NULLIF(c.orderNo, '') AS order_no, ISNULL(NULLIF(c.shopName, ''), N'未知店铺') AS shop_name FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) WHERE c.createdAt >= @start_time AND c.createdAt < @end_time AND NULLIF(c.orderNo, '') IS NOT NULL AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%') ), joined_tickets AS ( SELECT o.order_no, o.shop_name, i.StatusId, i.SubmitTime, i.kfshTime, i.cwshTime, DATEDIFF(minute, i.SubmitTime, i.kfshTime) AS submit_to_kf_minutes, DATEDIFF(minute, i.kfshTime, i.cwshTime) AS kf_to_cw_minutes FROM chat_orders AS o LEFT JOIN dbo.fys_Ticketing_install AS i WITH (NOLOCK) ON i.OrderNum = o.order_no ) SELECT CAST(@start_time AS date) AS start_date, CAST(DATEADD(day, -1, @end_time) AS date) AS end_date, (SELECT COUNT(1) FROM chat_orders) AS chat_order_count, COUNT(DISTINCT CASE WHEN SubmitTime IS NOT NULL THEN order_no END) AS matched_ticket_orders, COUNT(CASE WHEN SubmitTime IS NOT NULL THEN 1 END) AS matched_ticket_rows, SUM(CASE WHEN StatusId = 2 THEN 1 ELSE 0 END) AS status_done_count, SUM(CASE WHEN StatusId <> 2 AND StatusId IS NOT NULL THEN 1 ELSE 0 END) AS status_pending_count, SUM(CASE WHEN SubmitTime IS NOT NULL AND kfshTime IS NULL THEN 1 ELSE 0 END) AS kf_audit_pending_count, SUM(CASE WHEN SubmitTime IS NOT NULL AND kfshTime IS NOT NULL AND cwshTime IS NULL THEN 1 ELSE 0 END) AS cw_audit_pending_count, CAST(AVG(CASE WHEN submit_to_kf_minutes BETWEEN 0 AND 43200 THEN submit_to_kf_minutes / 60.0 END) AS decimal(10,2)) AS avg_submit_to_kf_hours, CAST(AVG(CASE WHEN kf_to_cw_minutes BETWEEN 0 AND 43200 THEN kf_to_cw_minutes / 60.0 END) AS decimal(10,2)) AS avg_kf_to_cw_hours FROM joined_tickets; """ def _build_ticket_reason_sql(start_date: dt.date, end_date: dt.date, shop: str, limit: int) -> str: shop_escaped = _sql_escape(shop.strip()) return f""" USE [JSDataMiddlePlatform]; SET NOCOUNT ON; DECLARE @start_time datetime2 = CAST('{start_date.isoformat()}' AS datetime2); DECLARE @end_time datetime2 = DATEADD(day, 1, CAST('{end_date.isoformat()}' AS datetime2)); DECLARE @shop nvarchar(100) = N'{shop_escaped}'; ;WITH chat_orders AS ( SELECT DISTINCT NULLIF(c.orderNo, '') AS order_no, ISNULL(NULLIF(c.shopName, ''), N'未知店铺') AS shop_name FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) WHERE c.createdAt >= @start_time AND c.createdAt < @end_time AND NULLIF(c.orderNo, '') IS NOT NULL AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%') ) SELECT TOP ({limit}) o.shop_name, ISNULL(NULLIF(i.ErrorReason, ''), N'未填写') AS ticket_error_reason, ISNULL(NULLIF(i.Type, ''), N'未填写') AS ticket_type, ISNULL(CAST(i.StatusId AS varchar(20)), 'NULL') AS status_id, COUNT(1) AS ticket_count FROM chat_orders AS o JOIN dbo.fys_Ticketing_install AS i WITH (NOLOCK) ON i.OrderNum = o.order_no GROUP BY o.shop_name, ISNULL(NULLIF(i.ErrorReason, ''), N'未填写'), ISNULL(NULLIF(i.Type, ''), N'未填写'), ISNULL(CAST(i.StatusId AS varchar(20)), 'NULL') ORDER BY ticket_count DESC, o.shop_name ASC; """ def _build_ticket_item_sql(start_date: dt.date, end_date: dt.date, shop: str, limit: int) -> str: shop_escaped = _sql_escape(shop.strip()) return f""" USE [JSDataMiddlePlatform]; SET NOCOUNT ON; DECLARE @start_time datetime2 = CAST('{start_date.isoformat()}' AS datetime2); DECLARE @end_time datetime2 = DATEADD(day, 1, CAST('{end_date.isoformat()}' AS datetime2)); DECLARE @shop nvarchar(100) = N'{shop_escaped}'; ;WITH chat_orders AS ( SELECT DISTINCT NULLIF(c.orderNo, '') AS order_no, ISNULL(NULLIF(c.shopName, ''), N'未知店铺') AS shop_name FROM dbo.pc_kfChatLog AS c WITH (NOLOCK) WHERE c.createdAt >= @start_time AND c.createdAt < @end_time AND NULLIF(c.orderNo, '') IS NOT NULL AND (@shop = N'' OR c.shopName LIKE N'%' + @shop + N'%') ) SELECT TOP ({limit}) o.shop_name, ISNULL(NULLIF(ii.remark, ''), N'未填写') AS install_item_remark, ISNULL(NULLIF(ii.scmname, ''), N'未填写') AS install_provider, COUNT(1) AS item_count, COUNT(DISTINCT ii.Sourceno) AS order_count FROM chat_orders AS o JOIN dbo.fys_Ticketing_install_item AS ii WITH (NOLOCK) ON ii.Sourceno = o.order_no GROUP BY o.shop_name, ISNULL(NULLIF(ii.remark, ''), N'未填写'), ISNULL(NULLIF(ii.scmname, ''), N'未填写') ORDER BY item_count DESC, order_count DESC; """ def _build_platform_snapshot_sql(start_date: dt.date, end_date: dt.date, shop: str) -> str: shop_escaped = _sql_escape(shop.strip()) return f""" USE [JSDataMiddlePlatform]; SET NOCOUNT ON; DECLARE @start_date date = '{start_date.isoformat()}'; DECLARE @end_date date = '{end_date.isoformat()}'; DECLARE @shop nvarchar(100) = N'{shop_escaped}'; WITH src AS ( SELECT 'jd_customerServiceScore' AS metric_source, TRY_CONVERT(date, REPLACE(NULLIF([date], ''), '/', '-')) AS metric_date, ISNULL(NULLIF(shopName, ''), N'未知店铺') AS shop_name FROM dbo.jd_customerServiceScore WITH (NOLOCK) WHERE (@shop = N'' OR shopName LIKE N'%' + @shop + N'%') UNION ALL SELECT 'jd_customerServiceDuration', TRY_CONVERT(date, REPLACE(NULLIF([date], ''), '/', '-')), ISNULL(NULLIF(shopName, ''), N'未知店铺') FROM dbo.jd_customerServiceDuration WITH (NOLOCK) WHERE (@shop = N'' OR shopName LIKE N'%' + @shop + N'%') UNION ALL SELECT 'pdd_DataCustomerService', TRY_CONVERT(date, REPLACE(NULLIF(createTime, ''), '/', '-')), ISNULL(NULLIF(shopName, ''), N'未知店铺') FROM dbo.pdd_DataCustomerService WITH (NOLOCK) WHERE (@shop = N'' OR shopName LIKE N'%' + @shop + N'%') UNION ALL SELECT 'pdd_MallServiceScore', COALESCE( TRY_CONVERT(date, REPLACE(NULLIF(createTime, ''), '/', '-')), TRY_CONVERT(date, createdAt) ), ISNULL(NULLIF(shopName, ''), N'未知店铺') FROM dbo.pdd_MallServiceScore WITH (NOLOCK) WHERE (@shop = N'' OR shopName LIKE N'%' + @shop + N'%') ) SELECT metric_source, COUNT(1) AS rows_total, MIN(metric_date) AS min_metric_date, MAX(metric_date) AS max_metric_date, SUM(CASE WHEN metric_date >= @start_date AND metric_date <= @end_date THEN 1 ELSE 0 END) AS rows_in_query_range, CASE WHEN MAX(metric_date) IS NULL THEN '无数据' WHEN DATEDIFF(day, MAX(metric_date), @end_date) <= 30 THEN '新鲜(<=30天)' WHEN DATEDIFF(day, MAX(metric_date), @end_date) <= 180 THEN '可参考(31-180天)' ELSE '过期(>180天)' END AS freshness_flag FROM src GROUP BY metric_source ORDER BY metric_source ASC; """ def main() -> int: today = dt.date.today() default_start = today - dt.timedelta(days=6) parser = argparse.ArgumentParser(description="客服用户反馈体验/话术分析(增强版,含售前售后分岗评分)") parser.add_argument("--start", type=_parse_date, default=default_start, help="开始日期 YYYY-MM-DD(默认近7天)") parser.add_argument("--end", type=_parse_date, default=today, help="结束日期 YYYY-MM-DD(默认今天)") parser.add_argument("--shop", default="", help="店铺关键词,可选") parser.add_argument("--limit", type=int, default=20, help="每个结果集TOP条数") parser.add_argument("--agent-max-rows", type=int, default=15000, help="客服评分阶段最多读取会话条数") args = parser.parse_args() if args.start > args.end: print("start date must be <= end date", file=sys.stderr) return 2 if (args.end - args.start).days > 366: print("date range too large, please keep within 367 days", file=sys.stderr) return 2 limit = max(1, min(args.limit, 200)) agent_max_rows = max(1000, min(args.agent_max_rows, 60000)) base_cmd = [ BASE_BIN, "--start", args.start.isoformat(), "--end", args.end.isoformat(), "--limit", str(limit), "--agent-max-rows", str(agent_max_rows), ] if args.shop: base_cmd.extend(["--shop", args.shop]) base_proc = subprocess.run(base_cmd, capture_output=True, text=True) if base_proc.returncode != 0: err = (base_proc.stderr or base_proc.stdout or "base analysis failed").strip() print(err, file=sys.stderr) return base_proc.returncode base_blocks, _ = _split_result_sets(base_proc.stdout or "") # Keep base result sets 1..8, replace legacy set9 with role-split score set for idx in sorted(base_blocks.keys()): if idx <= 8: print("\n".join(base_blocks[idx])) next_idx = 9 try: role_rows = _compute_role_score_rows( start_date=args.start, end_date=args.end, shop=args.shop, limit=limit, agent_max_rows=agent_max_rows, ) _print_result_set( next_idx, [ "shop_name", "agent_name", "position_role", "role_source", "roster_position_role", "roster_shop_name", "roster_display_name", "session_count", "primary_session_count", "message_count", "inquiry_order_count", "converted_order_count", "inquiry_conversion_rate_pct", "ticket_linked_order_count", "ticket_resolved_order_count", "ticket_resolve_rate_pct", "avg_submit_to_kf_hours", "external_month", "external_shop_name", "external_inquiry_count", "external_paid_next_count", "external_paid_final_count", "external_conv_next_pct", "external_conv_final_pct", "external_chat_count", "external_eval_show_count", "external_eval_join_count", "external_customer_satisfaction_pct", "external_service_score_5", "resp_ability_score", "satisfaction_chat_score", "satisfaction_external_score", "satisfaction_score", "satisfaction_source", "conversion_score", "conversion_source", "resolution_score", "timeliness_score", "risk_control_score", "role_capability_score", "role_grade", "is_reliable_sample", "sample_scope", "score_rule_note", ], role_rows, ) except Exception as e: _print_result_set(next_idx, ["error"], [{"error": f"分岗评分计算失败: {e}"}]) next_idx += 1 try: summary_rows = _run_company_query(_build_ticket_summary_sql(args.start, args.end, args.shop)) reason_rows = _run_company_query(_build_ticket_reason_sql(args.start, args.end, args.shop, limit)) item_rows = _run_company_query(_build_ticket_item_sql(args.start, args.end, args.shop, limit)) platform_rows = _run_company_query(_build_platform_snapshot_sql(args.start, args.end, args.shop)) _print_result_set( next_idx, [ "start_date", "end_date", "chat_order_count", "matched_ticket_orders", "matched_ticket_rows", "status_done_count", "status_pending_count", "kf_audit_pending_count", "cw_audit_pending_count", "avg_submit_to_kf_hours", "avg_kf_to_cw_hours", ], summary_rows, ) next_idx += 1 _print_result_set( next_idx, ["shop_name", "ticket_error_reason", "ticket_type", "status_id", "ticket_count"], reason_rows, ) next_idx += 1 _print_result_set( next_idx, ["shop_name", "install_item_remark", "install_provider", "item_count", "order_count"], item_rows, ) next_idx += 1 _print_result_set( next_idx, [ "metric_source", "rows_total", "min_metric_date", "max_metric_date", "rows_in_query_range", "freshness_flag", ], platform_rows, ) next_idx += 1 except Exception as e: _print_result_set(next_idx, ["error"], [{"error": f"增强结果集计算失败: {e}"}]) next_idx += 1 print(f"TOTAL_RESULT_SETS: {next_idx - 1}") return 0 if __name__ == "__main__": raise SystemExit(main())