phone-act-x 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phone_act_x-1.0.0/PKG-INFO +7 -0
- phone_act_x-1.0.0/phone_act/__init__.py +22 -0
- phone_act_x-1.0.0/phone_act/engine.py +429 -0
- phone_act_x-1.0.0/phone_act_x.egg-info/PKG-INFO +7 -0
- phone_act_x-1.0.0/phone_act_x.egg-info/SOURCES.txt +9 -0
- phone_act_x-1.0.0/phone_act_x.egg-info/dependency_links.txt +1 -0
- phone_act_x-1.0.0/phone_act_x.egg-info/entry_points.txt +2 -0
- phone_act_x-1.0.0/phone_act_x.egg-info/top_level.txt +1 -0
- phone_act_x-1.0.0/pyproject.toml +19 -0
- phone_act_x-1.0.0/setup.cfg +4 -0
- phone_act_x-1.0.0/tests/test_phone_act.py +139 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
phone_act — 微信视频号私信自动回复引擎
|
|
3
|
+
=========================================
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
from phone_act import process, PhoneAct
|
|
7
|
+
|
|
8
|
+
# 快捷方式
|
|
9
|
+
result = process("我征信不好")
|
|
10
|
+
if result["matched"]:
|
|
11
|
+
wechat_send(result["reply"])
|
|
12
|
+
|
|
13
|
+
# 完整控制
|
|
14
|
+
bot = PhoneAct()
|
|
15
|
+
bot.add_rule("欢迎", ["你好"], "您好!")
|
|
16
|
+
result = bot.process("你好")
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from phone_act.engine import PhoneAct, process, get_bot, clean_text, encode_phone, decode_phone
|
|
20
|
+
|
|
21
|
+
__version__ = "1.0.0"
|
|
22
|
+
__all__ = ["PhoneAct", "process", "get_bot", "clean_text", "encode_phone", "decode_phone"]
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""
|
|
2
|
+
phone_act 引擎 — 关键词规则匹配 + 频控 + 手机号编码
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from datetime import datetime, timezone, timedelta
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _default_data_dir() -> str:
|
|
17
|
+
"""数据文件目录,优先当前目录,fallback 到包目录"""
|
|
18
|
+
cwd = os.getcwd()
|
|
19
|
+
if os.path.isdir(cwd):
|
|
20
|
+
return cwd
|
|
21
|
+
return _HERE
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _now() -> str:
|
|
25
|
+
bj = timezone(timedelta(hours=8))
|
|
26
|
+
return datetime.now(timezone.utc).astimezone(bj).strftime("%Y-%m-%d %H:%M:%S")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ===================================================================
|
|
30
|
+
# 默认规则(贷款业务)
|
|
31
|
+
# ===================================================================
|
|
32
|
+
|
|
33
|
+
DEFAULT_RULES = [
|
|
34
|
+
{
|
|
35
|
+
"id": 1,
|
|
36
|
+
"name": "联系方式",
|
|
37
|
+
"keywords": ["怎么联系", "联系方式", "电话", "加你", "加您", "加微信",
|
|
38
|
+
"联系你", "联系您", "怎么加", "加个微信", "留个电话"],
|
|
39
|
+
"match_mode": "any",
|
|
40
|
+
"reply": "幺捌伍幺叁零陆陆伍柒贰",
|
|
41
|
+
"enabled": True,
|
|
42
|
+
"priority": 20,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": 2,
|
|
46
|
+
"name": "征信不好→问爱人",
|
|
47
|
+
"keywords": ["征信不好", "征信不太好", "征信不行", "征信差", "征信有问题",
|
|
48
|
+
"征信花了", "征信黑", "征信不过", "征信没通过",
|
|
49
|
+
"逾期了", "有逾期", "当前逾期", "逾期过"],
|
|
50
|
+
"match_mode": "any",
|
|
51
|
+
"reply": "您爱人征信怎么样?",
|
|
52
|
+
"enabled": True,
|
|
53
|
+
"priority": 16,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": 3,
|
|
57
|
+
"name": "征信确认",
|
|
58
|
+
"keywords": ["征信", "逾期", "信用", "黑户", "失信", "黑名单",
|
|
59
|
+
"法院", "执行", "老赖"],
|
|
60
|
+
"match_mode": "any",
|
|
61
|
+
"reply": "您征信怎么样?有当前逾期吗?",
|
|
62
|
+
"enabled": True,
|
|
63
|
+
"priority": 15,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": 4,
|
|
67
|
+
"name": "要号码",
|
|
68
|
+
"keywords": ["然后", "接下来", "怎么操作", "要什么", "资料",
|
|
69
|
+
"提供什么", "需要什么", "怎么办"],
|
|
70
|
+
"match_mode": "any",
|
|
71
|
+
"reply": "您的号码是多少?",
|
|
72
|
+
"enabled": True,
|
|
73
|
+
"priority": 12,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"id": 5,
|
|
77
|
+
"name": "贷款咨询",
|
|
78
|
+
"keywords": ["贷款", "怎么贷", "怎么申请", "能贷多少", "能贷吗",
|
|
79
|
+
"借款", "借钱", "办贷款", "做贷款", "放款", "下款",
|
|
80
|
+
"利息", "利率", "额度", "怎么弄"],
|
|
81
|
+
"match_mode": "any",
|
|
82
|
+
"reply": "您今年多大了?征信怎么样?方便的话我先了解下您的情况。",
|
|
83
|
+
"enabled": True,
|
|
84
|
+
"priority": 10,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": 6,
|
|
88
|
+
"name": "打招呼/求助",
|
|
89
|
+
"keywords": ["你好", "在吗", "hello", "hi", "在不在", "咨询", "请问",
|
|
90
|
+
"帮助", "帮忙", "帮帮我", "急", "着急", "经理", "老师",
|
|
91
|
+
"能不能", "可以吗", "行吗"],
|
|
92
|
+
"match_mode": "any",
|
|
93
|
+
"reply": "您好!您今年多大了?",
|
|
94
|
+
"enabled": True,
|
|
95
|
+
"priority": 5,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"id": 99,
|
|
99
|
+
"name": "兜底",
|
|
100
|
+
"keywords": [],
|
|
101
|
+
"match_mode": "fallback",
|
|
102
|
+
"reply": "您好!方便说一下您今年多大了吗?我帮您看看能不能做。",
|
|
103
|
+
"enabled": True,
|
|
104
|
+
"priority": 0,
|
|
105
|
+
},
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ===================================================================
|
|
110
|
+
# 工具函数
|
|
111
|
+
# ===================================================================
|
|
112
|
+
|
|
113
|
+
def clean_text(text: str) -> str:
|
|
114
|
+
"""清洗客户消息:去标点、空格,提高命中率"""
|
|
115
|
+
text = text.strip()
|
|
116
|
+
text = re.sub(r"[,。!?、;:“”‘’()【】《》…—\s]+", "", text)
|
|
117
|
+
text = re.sub(r"[,\.!\?;:\"'\(\)\[\]{}@#$%^&*+=~`|<>/\\-]+", "", text)
|
|
118
|
+
return text.lower()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_DIGIT_MAP = {
|
|
122
|
+
"0": "零", "1": "壹", "2": "贰", "3": "叁", "4": "肆",
|
|
123
|
+
"5": "伍", "6": "陆", "7": "柒", "8": "捌", "9": "玖",
|
|
124
|
+
}
|
|
125
|
+
_REVERSE_MAP = {v: k for k, v in _DIGIT_MAP.items()}
|
|
126
|
+
_REVERSE_MAP["幺"] = "1"
|
|
127
|
+
_REVERSE_MAP["两"] = "2"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def encode_phone(phone: str) -> str:
|
|
131
|
+
"""13800138000 → 壹叁捌零零壹叁捌零零零"""
|
|
132
|
+
return "".join(_DIGIT_MAP.get(d, d) for d in phone)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def decode_phone(encoded: str) -> str:
|
|
136
|
+
"""幺捌伍幺叁零陆陆伍柒贰 → 18513066572"""
|
|
137
|
+
result = []
|
|
138
|
+
for ch in encoded:
|
|
139
|
+
d = _REVERSE_MAP.get(ch)
|
|
140
|
+
if d is not None:
|
|
141
|
+
result.append(d)
|
|
142
|
+
elif ch.isdigit():
|
|
143
|
+
result.append(ch)
|
|
144
|
+
return "".join(result)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ===================================================================
|
|
148
|
+
# 引擎
|
|
149
|
+
# ===================================================================
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class PhoneAct:
|
|
153
|
+
"""关键词自动回复引擎"""
|
|
154
|
+
|
|
155
|
+
def __init__(self, rules_file: str = None, cooldown: int = 60, max_per_minute: int = 30):
|
|
156
|
+
self.rules_file = rules_file or os.path.join(_default_data_dir(), "rules.json")
|
|
157
|
+
self.cooldown = cooldown
|
|
158
|
+
self.max_per_minute = max_per_minute
|
|
159
|
+
self._reply_times: dict[str, float] = {}
|
|
160
|
+
self._minute_count: list[tuple[str, int]] = []
|
|
161
|
+
|
|
162
|
+
# ====== 规则管理 ======
|
|
163
|
+
|
|
164
|
+
def load_rules(self) -> list[dict]:
|
|
165
|
+
if not os.path.exists(self.rules_file):
|
|
166
|
+
self.save_rules(DEFAULT_RULES)
|
|
167
|
+
return [r for r in DEFAULT_RULES if r.get("enabled", True)]
|
|
168
|
+
try:
|
|
169
|
+
with open(self.rules_file, "r", encoding="utf-8") as f:
|
|
170
|
+
rules = json.load(f)
|
|
171
|
+
except (json.JSONDecodeError, IOError):
|
|
172
|
+
rules = DEFAULT_RULES
|
|
173
|
+
return sorted(
|
|
174
|
+
[r for r in rules if r.get("enabled", True)],
|
|
175
|
+
key=lambda r: r.get("priority", 0),
|
|
176
|
+
reverse=True,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def save_rules(self, rules: list[dict]) -> None:
|
|
180
|
+
with open(self.rules_file, "w", encoding="utf-8") as f:
|
|
181
|
+
json.dump(rules, f, ensure_ascii=False, indent=2)
|
|
182
|
+
|
|
183
|
+
def get_all_rules(self) -> list[dict]:
|
|
184
|
+
if not os.path.exists(self.rules_file):
|
|
185
|
+
return list(DEFAULT_RULES)
|
|
186
|
+
try:
|
|
187
|
+
with open(self.rules_file, "r", encoding="utf-8") as f:
|
|
188
|
+
return json.load(f)
|
|
189
|
+
except (json.JSONDecodeError, IOError):
|
|
190
|
+
return list(DEFAULT_RULES)
|
|
191
|
+
|
|
192
|
+
def add_rule(self, name: str, keywords: list[str], reply: str,
|
|
193
|
+
match_mode: str = "any", priority: int = 0) -> dict:
|
|
194
|
+
rules = self.get_all_rules()
|
|
195
|
+
new_id = max((r.get("id", 0) for r in rules), default=0) + 1
|
|
196
|
+
rule = {
|
|
197
|
+
"id": new_id, "name": name, "keywords": keywords,
|
|
198
|
+
"match_mode": match_mode, "reply": reply,
|
|
199
|
+
"enabled": True, "priority": priority,
|
|
200
|
+
}
|
|
201
|
+
rules.append(rule)
|
|
202
|
+
self.save_rules(rules)
|
|
203
|
+
return rule
|
|
204
|
+
|
|
205
|
+
def update_rule(self, rule_id: int, **kwargs) -> dict | None:
|
|
206
|
+
rules = self.get_all_rules()
|
|
207
|
+
for r in rules:
|
|
208
|
+
if r.get("id") == rule_id:
|
|
209
|
+
r.update(kwargs)
|
|
210
|
+
self.save_rules(rules)
|
|
211
|
+
return r
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
def delete_rule(self, rule_id: int) -> bool:
|
|
215
|
+
rules = self.get_all_rules()
|
|
216
|
+
new_rules = [r for r in rules if r.get("id") != rule_id]
|
|
217
|
+
if len(new_rules) < len(rules):
|
|
218
|
+
self.save_rules(new_rules)
|
|
219
|
+
return True
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
def toggle_rule(self, rule_id: int) -> dict | None:
|
|
223
|
+
rules = self.get_all_rules()
|
|
224
|
+
for r in rules:
|
|
225
|
+
if r.get("id") == rule_id:
|
|
226
|
+
r["enabled"] = not r.get("enabled", True)
|
|
227
|
+
self.save_rules(rules)
|
|
228
|
+
return r
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
# ====== 消息处理 ======
|
|
232
|
+
|
|
233
|
+
def process(self, content: str, from_user: str = "",
|
|
234
|
+
conversation_id: str = "") -> dict:
|
|
235
|
+
content = content.strip()
|
|
236
|
+
if not content:
|
|
237
|
+
return {"matched": False, "reply": "", "reason": "消息为空"}
|
|
238
|
+
|
|
239
|
+
rules = self.load_rules()
|
|
240
|
+
if not rules:
|
|
241
|
+
return {"matched": False, "reply": "", "reason": "没有启用的规则"}
|
|
242
|
+
|
|
243
|
+
cleaned = clean_text(content)
|
|
244
|
+
cid = conversation_id or from_user or "default"
|
|
245
|
+
|
|
246
|
+
for rule in rules:
|
|
247
|
+
mode = rule.get("match_mode", "any")
|
|
248
|
+
reply_text = rule.get("reply", "").strip()
|
|
249
|
+
|
|
250
|
+
if mode == "fallback":
|
|
251
|
+
if not reply_text:
|
|
252
|
+
continue
|
|
253
|
+
if not self._check_rate(cid):
|
|
254
|
+
return {"matched": True, "reply": "", "reason": "频控冷却中",
|
|
255
|
+
"keyword": "(兜底)", "rule_id": rule["id"]}
|
|
256
|
+
self._reply_times[cid] = time.time()
|
|
257
|
+
self._log(content, reply_text, from_user, cid, "(兜底)", rule["id"])
|
|
258
|
+
return {
|
|
259
|
+
"matched": True, "reply": reply_text,
|
|
260
|
+
"keyword": "(兜底)", "rule_id": rule["id"],
|
|
261
|
+
"rule_name": rule.get("name", ""), "match_mode": "fallback",
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
keywords = rule.get("keywords", [])
|
|
265
|
+
if not keywords or not reply_text:
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
kw = self._match(keywords, cleaned, mode, content)
|
|
269
|
+
if not kw:
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
if not self._check_rate(cid):
|
|
273
|
+
return {
|
|
274
|
+
"matched": True, "reply": "",
|
|
275
|
+
"reason": "冷却中", "keyword": kw, "rule_id": rule["id"],
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
self._reply_times[cid] = time.time()
|
|
279
|
+
self._log(content, reply_text, from_user, cid, kw, rule["id"])
|
|
280
|
+
return {
|
|
281
|
+
"matched": True, "reply": reply_text,
|
|
282
|
+
"keyword": kw, "rule_id": rule["id"],
|
|
283
|
+
"rule_name": rule.get("name", ""), "match_mode": mode,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
self._log(content, "", from_user, cid, "", None)
|
|
287
|
+
return {"matched": False, "reply": "", "reason": "无匹配规则"}
|
|
288
|
+
|
|
289
|
+
def process_batch(self, messages: list[dict]) -> list[dict]:
|
|
290
|
+
return [self.process(**m) if isinstance(m, dict) else
|
|
291
|
+
self.process(str(m)) for m in messages]
|
|
292
|
+
|
|
293
|
+
# ====== 内部 ======
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _match(keywords: list[str], cleaned: str, mode: str,
|
|
297
|
+
original: str = "") -> str | None:
|
|
298
|
+
if mode == "exact":
|
|
299
|
+
for kw in keywords:
|
|
300
|
+
if original.strip() == kw:
|
|
301
|
+
return kw
|
|
302
|
+
elif mode == "all":
|
|
303
|
+
hits = [kw for kw in keywords if kw in cleaned]
|
|
304
|
+
return ", ".join(hits) if len(hits) == len(keywords) else None
|
|
305
|
+
else:
|
|
306
|
+
for kw in keywords:
|
|
307
|
+
if kw in cleaned:
|
|
308
|
+
return kw
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
def _check_rate(self, cid: str) -> bool:
|
|
312
|
+
if cid in self._reply_times:
|
|
313
|
+
if time.time() - self._reply_times[cid] < self.cooldown:
|
|
314
|
+
return False
|
|
315
|
+
return self._check_global_rate()
|
|
316
|
+
|
|
317
|
+
def _check_global_rate(self) -> bool:
|
|
318
|
+
now = time.time()
|
|
319
|
+
minute_key = time.strftime("%Y%m%d%H%M", time.localtime(now))
|
|
320
|
+
self._minute_count = [
|
|
321
|
+
(k, c) for k, c in self._minute_count
|
|
322
|
+
if now - self._parse_minute_ts(k) < 120
|
|
323
|
+
]
|
|
324
|
+
current = sum(c for k, c in self._minute_count if k == minute_key)
|
|
325
|
+
if current >= self.max_per_minute:
|
|
326
|
+
return False
|
|
327
|
+
for i, (k, c) in enumerate(self._minute_count):
|
|
328
|
+
if k == minute_key:
|
|
329
|
+
self._minute_count[i] = (k, c + 1)
|
|
330
|
+
return True
|
|
331
|
+
self._minute_count.append((minute_key, 1))
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _parse_minute_ts(key: str) -> float:
|
|
336
|
+
try:
|
|
337
|
+
return time.mktime(time.strptime(key, "%Y%m%d%H%M"))
|
|
338
|
+
except (ValueError, OverflowError):
|
|
339
|
+
return 0
|
|
340
|
+
|
|
341
|
+
def _log(self, content: str, reply: str, from_user: str,
|
|
342
|
+
conversation_id: str, keyword: str, rule_id: int | None) -> None:
|
|
343
|
+
log_file = os.path.join(_default_data_dir(), "message_logs.json")
|
|
344
|
+
entry = {
|
|
345
|
+
"from_user": from_user,
|
|
346
|
+
"conversation_id": conversation_id,
|
|
347
|
+
"content": content[:200],
|
|
348
|
+
"reply": reply[:200],
|
|
349
|
+
"keyword": keyword,
|
|
350
|
+
"rule_id": rule_id,
|
|
351
|
+
"matched": bool(reply),
|
|
352
|
+
"time": _now(),
|
|
353
|
+
}
|
|
354
|
+
try:
|
|
355
|
+
with open(log_file, "a", encoding="utf-8") as f:
|
|
356
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
357
|
+
except IOError:
|
|
358
|
+
pass
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ===================================================================
|
|
362
|
+
# 全局单例
|
|
363
|
+
# ===================================================================
|
|
364
|
+
|
|
365
|
+
_bot: PhoneAct | None = None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def get_bot() -> PhoneAct:
|
|
369
|
+
global _bot
|
|
370
|
+
if _bot is None:
|
|
371
|
+
_bot = PhoneAct()
|
|
372
|
+
return _bot
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def process(content: str, from_user: str = "", conversation_id: str = "") -> dict:
|
|
376
|
+
"""快捷函数 — workbuddy 里直接调这个"""
|
|
377
|
+
return get_bot().process(content, from_user=from_user, conversation_id=conversation_id)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ===================================================================
|
|
381
|
+
# CLI
|
|
382
|
+
# ===================================================================
|
|
383
|
+
|
|
384
|
+
def main():
|
|
385
|
+
"""CLI 入口: phone-act '消息'"""
|
|
386
|
+
bot = PhoneAct()
|
|
387
|
+
|
|
388
|
+
if len(sys.argv) > 1:
|
|
389
|
+
arg = sys.argv[1]
|
|
390
|
+
if arg in ("-h", "--help"):
|
|
391
|
+
print("phone-act — 微信视频号私信自动回复")
|
|
392
|
+
print("用法:")
|
|
393
|
+
print(" phone-act '消息内容' 匹配消息")
|
|
394
|
+
print(" echo '消息' | phone-act 管道输入")
|
|
395
|
+
print(" phone-act --rules 查看所有规则")
|
|
396
|
+
print(" phone-act --init 生成默认规则文件 rules.json")
|
|
397
|
+
print(" phone-act --encode 13800138000 手机号编码")
|
|
398
|
+
sys.exit(0)
|
|
399
|
+
if arg == "--rules":
|
|
400
|
+
rules = bot.get_all_rules()
|
|
401
|
+
print(json.dumps(rules, ensure_ascii=False, indent=2))
|
|
402
|
+
sys.exit(0)
|
|
403
|
+
if arg == "--init":
|
|
404
|
+
bot.save_rules(DEFAULT_RULES)
|
|
405
|
+
print(f"已生成 rules.json → {bot.rules_file}")
|
|
406
|
+
sys.exit(0)
|
|
407
|
+
if arg == "--encode":
|
|
408
|
+
phone = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
409
|
+
if phone.isdigit() and len(phone) == 11:
|
|
410
|
+
print(f"原始: {phone}")
|
|
411
|
+
print(f"编码: {encode_phone(phone)}")
|
|
412
|
+
else:
|
|
413
|
+
print("请输入11位手机号")
|
|
414
|
+
sys.exit(0)
|
|
415
|
+
if arg == "--decode":
|
|
416
|
+
encoded = " ".join(sys.argv[2:]) if len(sys.argv) > 2 else ""
|
|
417
|
+
if encoded:
|
|
418
|
+
print(f"解码: {decode_phone(encoded)}")
|
|
419
|
+
sys.exit(0)
|
|
420
|
+
|
|
421
|
+
msg = " ".join(sys.argv[1:])
|
|
422
|
+
else:
|
|
423
|
+
msg = sys.stdin.read().strip()
|
|
424
|
+
if not msg:
|
|
425
|
+
print("用法: phone-act '消息内容'")
|
|
426
|
+
sys.exit(0)
|
|
427
|
+
|
|
428
|
+
result = bot.process(msg)
|
|
429
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
phone_act/__init__.py
|
|
3
|
+
phone_act/engine.py
|
|
4
|
+
phone_act_x.egg-info/PKG-INFO
|
|
5
|
+
phone_act_x.egg-info/SOURCES.txt
|
|
6
|
+
phone_act_x.egg-info/dependency_links.txt
|
|
7
|
+
phone_act_x.egg-info/entry_points.txt
|
|
8
|
+
phone_act_x.egg-info/top_level.txt
|
|
9
|
+
tests/test_phone_act.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
phone_act
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "phone-act-x"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "微信视频号私信自动回复引擎 — 贷款业务场景关键词匹配"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
authors = [{name = "phone-act"}]
|
|
12
|
+
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
phone-act = "phone_act.engine:main"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
include = ["phone_act*"]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""
|
|
2
|
+
phone_act 测试
|
|
3
|
+
运行: cd /Users/yuxia/Desktop/phone-act && python3 -m pytest tests/ -v
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
12
|
+
|
|
13
|
+
from phone_act import PhoneAct, process, clean_text, encode_phone, decode_phone
|
|
14
|
+
|
|
15
|
+
_passed = 0
|
|
16
|
+
_failed = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ok(label: str, result: dict, **expected):
|
|
20
|
+
global _passed, _failed
|
|
21
|
+
fail = False
|
|
22
|
+
for key, val in expected.items():
|
|
23
|
+
if result.get(key) != val:
|
|
24
|
+
print(f" ✗ {label}: {key}={result.get(key)!r}, 期望={val!r}")
|
|
25
|
+
fail = True
|
|
26
|
+
if not fail:
|
|
27
|
+
_passed += 1
|
|
28
|
+
print(f" ✓ {label}")
|
|
29
|
+
else:
|
|
30
|
+
_failed += 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def eq(label: str, got, expected):
|
|
34
|
+
global _passed, _failed
|
|
35
|
+
if got == expected:
|
|
36
|
+
_passed += 1
|
|
37
|
+
print(f" ✓ {label}")
|
|
38
|
+
else:
|
|
39
|
+
_failed += 1
|
|
40
|
+
print(f" ✗ {label}: got={got!r}, 期望={expected!r}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ============================================================
|
|
44
|
+
print("【真实客户消息】")
|
|
45
|
+
bot = PhoneAct()
|
|
46
|
+
|
|
47
|
+
ok("征信没问题 → 征信确认",
|
|
48
|
+
bot.process("征信没问题。贷款不出", conversation_id="t1"), matched=True, rule_id=3)
|
|
49
|
+
|
|
50
|
+
ok("征信不好 → 问爱人",
|
|
51
|
+
bot.process("我征信不好能贷吗", conversation_id="t2"), matched=True, rule_id=2)
|
|
52
|
+
|
|
53
|
+
ok("有逾期 → 问爱人",
|
|
54
|
+
bot.process("我有逾期怎么办", conversation_id="t3"), matched=True, rule_id=2)
|
|
55
|
+
|
|
56
|
+
ok("怎么贷款的 → 贷款咨询",
|
|
57
|
+
bot.process("你们怎么贷款的", conversation_id="t4"), matched=True, rule_id=5)
|
|
58
|
+
|
|
59
|
+
ok("怎么联系 → 联系方式",
|
|
60
|
+
bot.process("怎么联系你", conversation_id="t5"), matched=True, rule_id=1)
|
|
61
|
+
|
|
62
|
+
ok("你好 → 打招呼",
|
|
63
|
+
bot.process("你好", conversation_id="t6"), matched=True, rule_id=6)
|
|
64
|
+
|
|
65
|
+
ok("黑户 → 征信确认",
|
|
66
|
+
bot.process("我黑户能贷吗", conversation_id="t7"), matched=True, rule_id=3)
|
|
67
|
+
|
|
68
|
+
ok("帮帮我 → 打招呼",
|
|
69
|
+
bot.process("帮帮我急用钱", conversation_id="t8"), matched=True, rule_id=6)
|
|
70
|
+
|
|
71
|
+
ok("然后怎么操作 → 要号码",
|
|
72
|
+
bot.process("然后怎么操作", conversation_id="t9"), matched=True, rule_id=4)
|
|
73
|
+
|
|
74
|
+
ok("需要资料 → 要号码",
|
|
75
|
+
bot.process("需要什么资料", conversation_id="t10"), matched=True, rule_id=4)
|
|
76
|
+
|
|
77
|
+
ok("留电话 → 联系方式",
|
|
78
|
+
bot.process("留个电话", conversation_id="t11"), matched=True, rule_id=1)
|
|
79
|
+
|
|
80
|
+
ok("兜底",
|
|
81
|
+
bot.process("今天吃了没", conversation_id="t12"), matched=True, rule_id=99)
|
|
82
|
+
|
|
83
|
+
# ============================================================
|
|
84
|
+
print("\n【频控】")
|
|
85
|
+
bot2 = PhoneAct(cooldown=999)
|
|
86
|
+
r1 = bot2.process("你好", conversation_id="cool1")
|
|
87
|
+
ok("首次回复", r1, matched=True)
|
|
88
|
+
r2 = bot2.process("你好", conversation_id="cool1")
|
|
89
|
+
ok("冷却中", r2, matched=True, reply="")
|
|
90
|
+
r3 = bot2.process("你好", conversation_id="cool2")
|
|
91
|
+
ok("其他用户不受影响", r3, matched=True)
|
|
92
|
+
|
|
93
|
+
# ============================================================
|
|
94
|
+
print("\n【规则管理】")
|
|
95
|
+
|
|
96
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
97
|
+
json.dump([], f)
|
|
98
|
+
rf = f.name
|
|
99
|
+
|
|
100
|
+
bot3 = PhoneAct(rules_file=rf)
|
|
101
|
+
r = bot3.add_rule("测试", ["测试词"], "测试回复", priority=10)
|
|
102
|
+
ok("添加", r, name="测试")
|
|
103
|
+
ok("匹配", bot3.process("测试词来了", conversation_id="m1"), matched=True, reply="测试回复")
|
|
104
|
+
|
|
105
|
+
bot3.toggle_rule(r["id"])
|
|
106
|
+
ok("禁用后不匹配", bot3.process("测试词", conversation_id="m2"), matched=False)
|
|
107
|
+
|
|
108
|
+
bot3.toggle_rule(r["id"])
|
|
109
|
+
ok("重新启用", bot3.process("测试词", conversation_id="m3"), matched=True)
|
|
110
|
+
|
|
111
|
+
bot3.delete_rule(r["id"])
|
|
112
|
+
ok("删除", bot3.process("测试词", conversation_id="m4"), matched=False)
|
|
113
|
+
|
|
114
|
+
r2 = bot3.add_rule("更新测试", ["旧词"], "旧回复", priority=10)
|
|
115
|
+
bot3.update_rule(r2["id"], reply="已更新", keywords=["新关键词"])
|
|
116
|
+
ok("更新后", bot3.process("这个新关键词来了", conversation_id="m5"), matched=True, reply="已更新")
|
|
117
|
+
|
|
118
|
+
os.unlink(rf)
|
|
119
|
+
|
|
120
|
+
# ============================================================
|
|
121
|
+
print("\n【文本清洗】")
|
|
122
|
+
eq("中文标点", clean_text("你好,在吗?"), "你好在吗")
|
|
123
|
+
eq("混合标点", clean_text("征信没问题。 贷款不出!"), "征信没问题贷款不出")
|
|
124
|
+
|
|
125
|
+
# ============================================================
|
|
126
|
+
print("\n【手机号编码】")
|
|
127
|
+
eq("编码", encode_phone("18513066572"), "壹捌伍壹叁零陆陆伍柒贰")
|
|
128
|
+
eq("解码", decode_phone("幺捌伍幺叁零陆陆伍柒贰"), "18513066572")
|
|
129
|
+
|
|
130
|
+
# ============================================================
|
|
131
|
+
print("\n【快捷函数】")
|
|
132
|
+
ok("process()", process("你好", from_user="test", conversation_id="s1"), matched=True)
|
|
133
|
+
|
|
134
|
+
# ============================================================
|
|
135
|
+
print(f"\n{'='*40}")
|
|
136
|
+
print(f" 通过: {_passed} 失败: {_failed}")
|
|
137
|
+
print(f"{'='*40}")
|
|
138
|
+
|
|
139
|
+
sys.exit(0 if _failed == 0 else 1)
|