dalongxia/remote_sync/customer-service-analysis
2026-03-04 13:25:48 +08:00

1345 lines
47 KiB
Python
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())