doorcore 0.1.0__py3-none-any.whl
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.
- doorcore/__init__.py +9 -0
- doorcore/api.py +127 -0
- doorcore/cli.py +57 -0
- doorcore/core.py +454 -0
- doorcore/data/config.json +3 -0
- doorcore/data/corpus//321/207/320/257/320/265/321/210/320/277/320/226/321/205/342/225/221/320/243.txt +5045 -0
- doorcore/data/corpus//321/207/320/274/320/241/321/210/320/277/320/255.txt +569 -0
- doorcore/data/model.pkl +0 -0
- doorcore-0.1.0.dist-info/METADATA +66 -0
- doorcore-0.1.0.dist-info/RECORD +12 -0
- doorcore-0.1.0.dist-info/WHEEL +4 -0
- doorcore-0.1.0.dist-info/entry_points.txt +3 -0
doorcore/__init__.py
ADDED
doorcore/api.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# door-core v2 —— 零依赖 OpenAI 兼容 API + 可部署 WSGI application
|
|
2
|
+
# 本地运行: python api.py -> http://localhost:8765/v1
|
|
3
|
+
# 公网部署: 平台 import 本文件的 application(WSGI)即可,模型名 door-core
|
|
4
|
+
import os
|
|
5
|
+
import json
|
|
6
|
+
import importlib.resources as _resources
|
|
7
|
+
|
|
8
|
+
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
|
9
|
+
from doorcore.core import DoorCore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _pkg_path(*parts):
|
|
13
|
+
try:
|
|
14
|
+
return str(_resources.files("doorcore").joinpath(*parts))
|
|
15
|
+
except Exception:
|
|
16
|
+
return os.path.join(os.path.dirname(os.path.abspath(__file__)), *parts)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_config():
|
|
20
|
+
p = _pkg_path("data", "config.json")
|
|
21
|
+
if os.path.exists(p):
|
|
22
|
+
try:
|
|
23
|
+
return json.load(open(p, encoding="utf-8"))
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
return {"n": 3}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
cfg = load_config()
|
|
30
|
+
_AI = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_ai():
|
|
34
|
+
global _AI
|
|
35
|
+
if _AI is None:
|
|
36
|
+
n = int(cfg.get("n", 3))
|
|
37
|
+
_AI = DoorCore(n=n)
|
|
38
|
+
_AI.load_or_train()
|
|
39
|
+
return _AI
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _chat_reply(messages):
|
|
43
|
+
text = ""
|
|
44
|
+
for m in reversed(messages or []):
|
|
45
|
+
if m.get("role") == "user":
|
|
46
|
+
text = m.get("content", "")
|
|
47
|
+
break
|
|
48
|
+
return get_ai().ask(text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _completion_obj(reply):
|
|
52
|
+
return {
|
|
53
|
+
"object": "chat.completion",
|
|
54
|
+
"model": "door-core",
|
|
55
|
+
"choices": [{
|
|
56
|
+
"index": 0,
|
|
57
|
+
"message": {"role": "assistant", "content": reply},
|
|
58
|
+
"finish_reason": "stop",
|
|
59
|
+
}],
|
|
60
|
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _body_str(obj):
|
|
65
|
+
return json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Handler(BaseHTTPRequestHandler):
|
|
69
|
+
def _send(self, code, obj):
|
|
70
|
+
body = _body_str(obj)
|
|
71
|
+
self.send_response(code)
|
|
72
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
73
|
+
self.send_header("Content-Length", str(len(body)))
|
|
74
|
+
self.end_headers()
|
|
75
|
+
self.wfile.write(body)
|
|
76
|
+
|
|
77
|
+
def do_POST(self):
|
|
78
|
+
if self.path.rstrip("/") not in ("/v1/chat/completions", "/chat/completions"):
|
|
79
|
+
self._send(404, {"error": "not found"})
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
ln = int(self.headers.get("Content-Length", 0) or 0)
|
|
83
|
+
raw = self.rfile.read(ln) if ln else b""
|
|
84
|
+
data = json.loads(raw.decode("utf-8"))
|
|
85
|
+
except Exception as e:
|
|
86
|
+
self._send(400, {"error": str(e)})
|
|
87
|
+
return
|
|
88
|
+
self._send(200, _completion_obj(_chat_reply(data.get("messages", []))))
|
|
89
|
+
|
|
90
|
+
def log_message(self, *a):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def application(environ, start_response):
|
|
95
|
+
path = (environ.get("PATH_INFO", "") or "").rstrip("/")
|
|
96
|
+
if environ.get("REQUEST_METHOD") == "POST" and path in (
|
|
97
|
+
"/v1/chat/completions", "/chat/completions"):
|
|
98
|
+
try:
|
|
99
|
+
ln = int(environ.get("CONTENT_LENGTH", 0) or 0)
|
|
100
|
+
raw = environ["wsgi.input"].read(ln) if ln else b""
|
|
101
|
+
data = json.loads(raw.decode("utf-8"))
|
|
102
|
+
reply = _chat_reply(data.get("messages", []))
|
|
103
|
+
except Exception as e:
|
|
104
|
+
reply = "出错了:" + str(e)
|
|
105
|
+
body = _body_str(_completion_obj(reply))
|
|
106
|
+
start_response("200 OK", [
|
|
107
|
+
("Content-Type", "application/json; charset=utf-8"),
|
|
108
|
+
("Content-Length", str(len(body))),
|
|
109
|
+
])
|
|
110
|
+
return [body]
|
|
111
|
+
body = b'{"error":"not found"}'
|
|
112
|
+
start_response("404 Not Found", [
|
|
113
|
+
("Content-Type", "application/json"),
|
|
114
|
+
("Content-Length", str(len(body))),
|
|
115
|
+
])
|
|
116
|
+
return [body]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main():
|
|
120
|
+
port = int(os.environ.get("PORT", "8765"))
|
|
121
|
+
print(f"[door-core v2 API] http://localhost:{port}/v1 (模型名 door-core)")
|
|
122
|
+
httpd = ThreadingHTTPServer(("0.0.0.0", port), Handler)
|
|
123
|
+
httpd.serve_forever()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|
doorcore/cli.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# door-core v2 —— 命令行聊天(训练并从 .txt 语料加载,结果存 model.pkl)
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
5
|
+
import importlib.resources as _resources
|
|
6
|
+
|
|
7
|
+
from doorcore.core import DoorCore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _pkg_path(*parts):
|
|
11
|
+
try:
|
|
12
|
+
return str(_resources.files("doorcore").joinpath(*parts))
|
|
13
|
+
except Exception:
|
|
14
|
+
return os.path.join(os.path.dirname(os.path.abspath(__file__)), *parts)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_config():
|
|
18
|
+
p = _pkg_path("data", "config.json")
|
|
19
|
+
if os.path.exists(p):
|
|
20
|
+
try:
|
|
21
|
+
return json.load(open(p, encoding="utf-8"))
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
return {"n": 3}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main():
|
|
28
|
+
cfg = load_config()
|
|
29
|
+
n = int(cfg.get("n", 3))
|
|
30
|
+
ai = DoorCore(n=n)
|
|
31
|
+
|
|
32
|
+
force = "--retrain" in sys.argv
|
|
33
|
+
status = ai.load_or_train(force=force)
|
|
34
|
+
print(f"[door-core v2] 模型: {status} | 知识条目: {len(ai.kb)} | "
|
|
35
|
+
f"马尔可夫节点: {len(ai.model)}")
|
|
36
|
+
print("聊天开始(/retrain 重新训练, /quit 退出)")
|
|
37
|
+
|
|
38
|
+
while True:
|
|
39
|
+
try:
|
|
40
|
+
u = input("你> ").strip()
|
|
41
|
+
except (EOFError, KeyboardInterrupt):
|
|
42
|
+
print()
|
|
43
|
+
break
|
|
44
|
+
if not u:
|
|
45
|
+
continue
|
|
46
|
+
if u in ("/quit", "/exit"):
|
|
47
|
+
break
|
|
48
|
+
if u == "/retrain":
|
|
49
|
+
ai.load_corpus()
|
|
50
|
+
ai.save_model()
|
|
51
|
+
print(f"已重新训练:知识条目 {len(ai.kb)},马尔可夫节点 {len(ai.model)}")
|
|
52
|
+
continue
|
|
53
|
+
print("AI> " + ai.ask(u))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
doorcore/core.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# door-core v2 —— 马尔可夫本地 AI(语料库从 .txt 路径加载,不内置)
|
|
2
|
+
#
|
|
3
|
+
# 知识库格式(沿用旧版):每行 "问题===答案"
|
|
4
|
+
# - 含 === 的行:作为问答对进入检索库,同时喂给马尔可夫训练
|
|
5
|
+
# - 不含 === 的纯文本行:直接作为语料训练马尔可夫(可放任意 txt)
|
|
6
|
+
# 所有 .txt 都放在 config.json 里 "corpus" 指定的路径(文件或目录)下即可。
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import random
|
|
13
|
+
import pickle
|
|
14
|
+
import glob
|
|
15
|
+
import datetime
|
|
16
|
+
import importlib.resources as _resources
|
|
17
|
+
|
|
18
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pkg_path(*parts):
|
|
22
|
+
"""优先从安装包内定位资源,失败则回退到源码目录。"""
|
|
23
|
+
try:
|
|
24
|
+
return str(_resources.files("doorcore").joinpath(*parts))
|
|
25
|
+
except Exception:
|
|
26
|
+
return os.path.join(_HERE, *parts)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# 寒暄/问候:代码内的少量对话逻辑(不是知识库语料),短问候直接友好回复
|
|
30
|
+
_GREETING_KEYWORDS = ["你好", "您好", "嗨", "哈喽", "在吗", "在么",
|
|
31
|
+
"在不在", "早上好", "下午好", "晚上好",
|
|
32
|
+
"hello", "hi", "hey", "喂"]
|
|
33
|
+
_GREETING_FILLER = r'[\s,。!?!?,.,~~、;;::呀啊哦呢哈呦哟嘿嘛儿呗]'
|
|
34
|
+
_GREETING_REPLIES = [
|
|
35
|
+
"你好呀!我是 door-Core,一个完全离线、跑在你本地的智能助手。有什么想聊的或想问的?",
|
|
36
|
+
"嗨~我是 door-Core,本地离线 AI,随时陪你聊。",
|
|
37
|
+
"在的,我是 door-Core,一个完全本地运行的智能助手,问我就行~",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# 科学计算白名单:eval 只在下面这些名字里解析,__builtins__ 已清空,安全
|
|
42
|
+
import math as _math
|
|
43
|
+
_SAFE_MATH = {
|
|
44
|
+
# 基础
|
|
45
|
+
"abs": abs, "round": round, "min": min, "max": max, "pow": pow, "sum": sum,
|
|
46
|
+
# 幂 / 根 / 指数 / 对数
|
|
47
|
+
"sqrt": _math.sqrt, "cbrt": lambda x: x ** (1 / 3), "exp": _math.exp,
|
|
48
|
+
"log": _math.log, "log10": _math.log10, "log2": _math.log2,
|
|
49
|
+
"ln": _math.log, "lg": _math.log10,
|
|
50
|
+
# 取整
|
|
51
|
+
"floor": _math.floor, "ceil": _math.ceil,
|
|
52
|
+
# 三角(弧度)
|
|
53
|
+
"sin": _math.sin, "cos": _math.cos, "tan": _math.tan,
|
|
54
|
+
"asin": _math.asin, "acos": _math.acos, "atan": _math.atan,
|
|
55
|
+
"sinh": _math.sinh, "cosh": _math.cosh, "tanh": _math.tanh,
|
|
56
|
+
# 三角(角度制,方便直接算 sin(30))
|
|
57
|
+
"sind": lambda x: _math.sin(_math.radians(x)),
|
|
58
|
+
"cosd": lambda x: _math.cos(_math.radians(x)),
|
|
59
|
+
"tand": lambda x: _math.tan(_math.radians(x)),
|
|
60
|
+
"asind": lambda x: _math.degrees(_math.asin(x)),
|
|
61
|
+
"acosd": lambda x: _math.degrees(_math.acos(x)),
|
|
62
|
+
"atand": lambda x: _math.degrees(_math.atan(x)),
|
|
63
|
+
# 角度换算
|
|
64
|
+
"radians": _math.radians, "degrees": _math.degrees,
|
|
65
|
+
# 数论
|
|
66
|
+
"factorial": _math.factorial, "gcd": _math.gcd, "lcm": _math.lcm,
|
|
67
|
+
"hypot": _math.hypot, "atan2": _math.atan2,
|
|
68
|
+
"perm": _math.perm, "comb": _math.comb,
|
|
69
|
+
# 便捷
|
|
70
|
+
"平方": lambda x: x ** 2, "立方": lambda x: x ** 3, "倒数": lambda x: 1 / x,
|
|
71
|
+
# 常数
|
|
72
|
+
"pi": _math.pi, "e": _math.e, "tau": _math.tau,
|
|
73
|
+
"圆周率": _math.pi, "自然常数": _math.e,
|
|
74
|
+
# 中文别名(沿用旧版 + 扩充)
|
|
75
|
+
"绝对值": abs, "开方": _math.sqrt, "平方根": _math.sqrt, "根号": _math.sqrt,
|
|
76
|
+
"四舍五入": round, "取整": round, "向下取整": _math.floor, "向上取整": _math.ceil,
|
|
77
|
+
"阶乘": _math.factorial, "对数": _math.log, "常用对数": _math.log10,
|
|
78
|
+
"正弦": _math.sin, "余弦": _math.cos, "正切": _math.tan,
|
|
79
|
+
"反正弦": _math.asin, "反余弦": _math.acos, "反正切": _math.atan,
|
|
80
|
+
"双曲正弦": _math.sinh, "双曲余弦": _math.cosh, "双曲正切": _math.tanh,
|
|
81
|
+
"弧度": _math.radians, "角度": _math.degrees, "指数": _math.exp,
|
|
82
|
+
"最大": max, "最小": min, "求和": sum,
|
|
83
|
+
"排列": _math.perm, "组合": _math.comb,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# 角色扮演:触发词 -> 每句后缀(要加更多角色,照这个格式加即可)
|
|
88
|
+
ROLES = {
|
|
89
|
+
"猫娘": {
|
|
90
|
+
"triggers": ["猫娘", "喵娘", "catgirl", "猫娘模式", "扮演猫娘", "变成猫娘"],
|
|
91
|
+
"suffix": "喵",
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class DoorCore:
|
|
97
|
+
def __init__(self, corpus_path=None, n=3, model_path=None):
|
|
98
|
+
self.n = n
|
|
99
|
+
self.model = {} # (char,)*n -> [next_char, ...]
|
|
100
|
+
self.starters = [] # 句首 n-gram
|
|
101
|
+
self.kb = [] # [(q, a), ...] 检索库
|
|
102
|
+
self.jokes = []
|
|
103
|
+
self.trained = False
|
|
104
|
+
# 默认从包内 data 目录定位资源;传入参数可覆盖
|
|
105
|
+
self.corpus_path = corpus_path or _pkg_path("data", "corpus")
|
|
106
|
+
self.model_path = model_path or _pkg_path("data", "model.pkl")
|
|
107
|
+
self.role = None # 当前角色扮演(None=关闭)
|
|
108
|
+
|
|
109
|
+
# ---------------- 语料加载(从路径读 .txt) ----------------
|
|
110
|
+
def _iter_txt(self):
|
|
111
|
+
if os.path.isdir(self.corpus_path):
|
|
112
|
+
files = sorted(glob.glob(os.path.join(self.corpus_path, "*.txt")))
|
|
113
|
+
elif os.path.isfile(self.corpus_path):
|
|
114
|
+
files = [self.corpus_path]
|
|
115
|
+
else:
|
|
116
|
+
files = []
|
|
117
|
+
for p in files:
|
|
118
|
+
try:
|
|
119
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
120
|
+
yield p, f.read()
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
def load_corpus(self):
|
|
125
|
+
self.model = {}
|
|
126
|
+
self.starters = []
|
|
127
|
+
self.kb = []
|
|
128
|
+
self.jokes = []
|
|
129
|
+
for path, text in self._iter_txt():
|
|
130
|
+
self._ingest(text)
|
|
131
|
+
if self.kb or self.model:
|
|
132
|
+
self.trained = True
|
|
133
|
+
|
|
134
|
+
def _ingest(self, text):
|
|
135
|
+
for line in text.splitlines():
|
|
136
|
+
line = line.strip()
|
|
137
|
+
if not line:
|
|
138
|
+
continue
|
|
139
|
+
if "===" in line:
|
|
140
|
+
q, a = line.split("===", 1)
|
|
141
|
+
q, a = q.strip(), a.strip()
|
|
142
|
+
if q and a:
|
|
143
|
+
self.kb.append((q, a))
|
|
144
|
+
self._train_text(q + "。" + a)
|
|
145
|
+
else:
|
|
146
|
+
self._train_text(line)
|
|
147
|
+
|
|
148
|
+
# ---------------- 马尔可夫(字符级 n-gram) ----------------
|
|
149
|
+
def _train_text(self, text):
|
|
150
|
+
sents = re.split(r'[。!?!?\n;;]', text)
|
|
151
|
+
for sent in sents:
|
|
152
|
+
sent = sent.strip()
|
|
153
|
+
if len(sent) < self.n + 1:
|
|
154
|
+
continue
|
|
155
|
+
toks = list(sent)
|
|
156
|
+
self.starters.append(tuple(toks[:self.n]))
|
|
157
|
+
for i in range(len(toks) - self.n):
|
|
158
|
+
key = tuple(toks[i:i + self.n])
|
|
159
|
+
self.model.setdefault(key, []).append(toks[i + self.n])
|
|
160
|
+
if sents:
|
|
161
|
+
self.trained = True
|
|
162
|
+
|
|
163
|
+
def _generate(self, seed=None, max_len=40):
|
|
164
|
+
if not self.trained or not self.starters:
|
|
165
|
+
return ""
|
|
166
|
+
key = tuple(seed[-self.n:]) if seed else random.choice(self.starters)
|
|
167
|
+
if seed and key not in self.model:
|
|
168
|
+
key = random.choice(self.starters)
|
|
169
|
+
res = list(key)
|
|
170
|
+
for _ in range(max_len):
|
|
171
|
+
if key in self.model and self.model[key]:
|
|
172
|
+
res.append(random.choice(self.model[key]))
|
|
173
|
+
key = tuple(res[-self.n:])
|
|
174
|
+
else:
|
|
175
|
+
break
|
|
176
|
+
return "".join(res)
|
|
177
|
+
|
|
178
|
+
# ---------------- 检索(字符 bigram 余弦) ----------------
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _char_bigrams(s):
|
|
181
|
+
s = ''.join(re.findall(r'\w', s.lower()))
|
|
182
|
+
return [s[i:i + 2] for i in range(len(s) - 1)] or [s]
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _cosine(a, b):
|
|
186
|
+
sa = DoorCore._char_bigrams(a)
|
|
187
|
+
sb = DoorCore._char_bigrams(b)
|
|
188
|
+
va, vb = {}, {}
|
|
189
|
+
for g in sa:
|
|
190
|
+
va[g] = va.get(g, 0) + 1
|
|
191
|
+
for g in sb:
|
|
192
|
+
vb[g] = vb.get(g, 0) + 1
|
|
193
|
+
common = set(va) & set(vb)
|
|
194
|
+
num = sum(va[g] * vb[g] for g in common)
|
|
195
|
+
da = math.sqrt(sum(v * v for v in va.values()))
|
|
196
|
+
db = math.sqrt(sum(v * v for v in vb.values()))
|
|
197
|
+
return num / (da * db) if da and db else 0.0
|
|
198
|
+
|
|
199
|
+
def _retrieve(self, text, threshold=0.35):
|
|
200
|
+
best, best_score, best_q = None, 0.0, None
|
|
201
|
+
for q, a in self.kb:
|
|
202
|
+
score = self._cosine(text, q)
|
|
203
|
+
if q and q in text:
|
|
204
|
+
score = max(score, 0.95)
|
|
205
|
+
elif text and text in q and len(text) >= 4:
|
|
206
|
+
score = max(score, 0.95)
|
|
207
|
+
if score > best_score:
|
|
208
|
+
best, best_score, best_q = a, score, q
|
|
209
|
+
if best_score >= threshold:
|
|
210
|
+
return best, best_score, best_q
|
|
211
|
+
return None, best_score, best_q
|
|
212
|
+
|
|
213
|
+
# ---------------- 时间 / 意图 ----------------
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _is_time_query(t):
|
|
216
|
+
strong = ['几点', '几号', '星期', '周几', '礼拜', '点钟', '点了',
|
|
217
|
+
'什么时间', '什么日期', '几日', '几时']
|
|
218
|
+
if any(w in t for w in strong):
|
|
219
|
+
return True
|
|
220
|
+
weak = ['现在', '今天', '日期']
|
|
221
|
+
ask = ['几', '什么', '哪', '呢', '?', '?']
|
|
222
|
+
if any(w in t for w in weak) and any(q in t for q in ask):
|
|
223
|
+
return True
|
|
224
|
+
if t in ('现在', '今天', '日期', '今天呢', '现在呢', '时间'):
|
|
225
|
+
return True
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _time_reply():
|
|
230
|
+
now = datetime.datetime.now()
|
|
231
|
+
weekdays = ['星期一', '星期二', '星期三', '星期四', '星期五',
|
|
232
|
+
'星期六', '星期日']
|
|
233
|
+
wd = weekdays[now.weekday()]
|
|
234
|
+
h = now.hour
|
|
235
|
+
if 5 <= h < 8:
|
|
236
|
+
part = '早上'
|
|
237
|
+
elif 8 <= h < 11:
|
|
238
|
+
part = '上午'
|
|
239
|
+
elif 11 <= h < 13:
|
|
240
|
+
part = '中午'
|
|
241
|
+
elif 13 <= h < 18:
|
|
242
|
+
part = '下午'
|
|
243
|
+
elif 18 <= h < 24:
|
|
244
|
+
part = '晚上'
|
|
245
|
+
else:
|
|
246
|
+
part = '凌晨'
|
|
247
|
+
return (f"现在是 {now.year}年{now.month}月{now.day}日 "
|
|
248
|
+
f"{wd} {part}{now.hour}点{now.minute}分{now.second}秒"
|
|
249
|
+
f"(本机时间)。")
|
|
250
|
+
|
|
251
|
+
# ---------------- 寒暄识别 ----------------
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _is_greeting(t):
|
|
254
|
+
if len(t) > 12:
|
|
255
|
+
return False
|
|
256
|
+
stripped = t.lower()
|
|
257
|
+
for k in _GREETING_KEYWORDS:
|
|
258
|
+
stripped = stripped.replace(k, "")
|
|
259
|
+
stripped = re.sub(_GREETING_FILLER, "", stripped)
|
|
260
|
+
return len(stripped) == 0
|
|
261
|
+
|
|
262
|
+
# ---------------- 科学计算(安全 eval,仅白名单函数/常数 + 中文算式) ----------------
|
|
263
|
+
@staticmethod
|
|
264
|
+
def _calc(t):
|
|
265
|
+
try:
|
|
266
|
+
if len(t) > 200:
|
|
267
|
+
return None
|
|
268
|
+
expr = (t.translate(str.maketrans('-0123456789,',
|
|
269
|
+
'-0123456789,'))
|
|
270
|
+
.replace('(', '(').replace(')', ')')
|
|
271
|
+
.replace('%', '%').replace('!', '!').replace('^', '**'))
|
|
272
|
+
# 中文函数名 -> 英文别名
|
|
273
|
+
_CN_ALIAS = {
|
|
274
|
+
'绝对值': 'abs', '开方': 'sqrt', '平方根': 'sqrt', '根号': 'sqrt',
|
|
275
|
+
'四舍五入': 'round', '取整': 'round', '向下取整': 'floor',
|
|
276
|
+
'向上取整': 'ceil', '阶乘': 'factorial', '对数': 'log',
|
|
277
|
+
'常用对数': 'log10', '正弦': 'sin', '余弦': 'cos', '正切': 'tan',
|
|
278
|
+
'反正弦': 'asin', '反余弦': 'acos', '反正切': 'atan',
|
|
279
|
+
'双曲正弦': 'sinh', '双曲余弦': 'cosh', '双曲正切': 'tanh',
|
|
280
|
+
'弧度': 'radians', '角度': 'degrees', '指数': 'exp',
|
|
281
|
+
'最大': 'max', '最小': 'min', '求和': 'sum',
|
|
282
|
+
'排列': 'perm', '组合': 'comb',
|
|
283
|
+
'圆周率': 'pi', '自然常数': 'e',
|
|
284
|
+
}
|
|
285
|
+
for k, v in _CN_ALIAS.items():
|
|
286
|
+
expr = expr.replace(k, v)
|
|
287
|
+
|
|
288
|
+
# 中文数字 -> 阿拉伯数字
|
|
289
|
+
def zh_to_int(s):
|
|
290
|
+
digits = {'零': 0, '一': 1, '二': 2, '两': 2, '三': 3, '四': 4,
|
|
291
|
+
'五': 5, '六': 6, '七': 7, '八': 8, '九': 9}
|
|
292
|
+
units = {'十': 10, '百': 100, '千': 1000}
|
|
293
|
+
if '万' in s:
|
|
294
|
+
a, _, b = s.partition('万')
|
|
295
|
+
left = zh_to_int(a) if a else 0
|
|
296
|
+
right = zh_to_int(b) if b else 0
|
|
297
|
+
return (left or 0) * 10000 + (right or 0)
|
|
298
|
+
num, section = 0, 0
|
|
299
|
+
for ch in s:
|
|
300
|
+
if ch in digits:
|
|
301
|
+
section = digits[ch]
|
|
302
|
+
elif ch in units:
|
|
303
|
+
u = units[ch]
|
|
304
|
+
if section == 0:
|
|
305
|
+
section = 1
|
|
306
|
+
num += section * u
|
|
307
|
+
section = 0
|
|
308
|
+
else:
|
|
309
|
+
return None
|
|
310
|
+
return num + section
|
|
311
|
+
|
|
312
|
+
def _repl_cn(m):
|
|
313
|
+
v = zh_to_int(m.group(0))
|
|
314
|
+
return str(v) if v is not None else m.group(0)
|
|
315
|
+
|
|
316
|
+
expr = re.sub(r'[零一二两三四五六七八九十百千万]+', _repl_cn, expr)
|
|
317
|
+
# 的……次方 / 次幂
|
|
318
|
+
expr = re.sub(r'([0-9A-Za-z_]+)\s*的\s*'
|
|
319
|
+
r'([+-]?[0-9]+\.?[0-9]*)\s*次方', r'pow(\1,\2)', expr)
|
|
320
|
+
expr = re.sub(r'([0-9A-Za-z_]+)\s*的\s*'
|
|
321
|
+
r'([+-]?[0-9]+\.?[0-9]*)\s*次幂', r'pow(\1,\2)', expr)
|
|
322
|
+
# 一元函数:abs(5) / 5的绝对值 / sqrt16
|
|
323
|
+
_UNARY = ('abs', 'sqrt', 'round', 'floor', 'ceil', 'log', 'log10',
|
|
324
|
+
'log2', 'sin', 'cos', 'tan', 'exp', 'factorial', 'gcd',
|
|
325
|
+
'hypot', 'radians', 'degrees', 'min', 'max')
|
|
326
|
+
for en in _UNARY:
|
|
327
|
+
expr = re.sub(r'(?<![0-9A-Za-z])' + en + r'\s*'
|
|
328
|
+
r'([+-]?[0-9]+\.?[0-9]*)', en + r'(\1)', expr)
|
|
329
|
+
expr = re.sub(r'(?<![0-9A-Za-z])([+-]?[0-9]+\.?[0-9]*)\s*的\s*'
|
|
330
|
+
r'(abs|sqrt|round|floor|ceil|log|log10|log2|sin|cos|'
|
|
331
|
+
r'tan|exp|factorial|gcd|hypot|radians|degrees|min|max)',
|
|
332
|
+
r'\2(\1)', expr)
|
|
333
|
+
expr = re.sub(r'(?<![0-9A-Za-z])([+-]?[0-9]+\.?[0-9]*)\s*'
|
|
334
|
+
r'(abs|sqrt|round|floor|ceil|log|log10|log2|sin|cos|'
|
|
335
|
+
r'tan|exp|factorial|gcd|hypot|radians|degrees|min|max)'
|
|
336
|
+
r'(?![0-9A-Za-z])', r'\2(\1)', expr)
|
|
337
|
+
# 平方 / 立方(含「X的平方」「X的立方」)
|
|
338
|
+
expr = re.sub(r'([0-9)])\s*的\s*平方', r'\1**2', expr)
|
|
339
|
+
expr = re.sub(r'([0-9)])\s*的\s*立方', r'\1**3', expr)
|
|
340
|
+
expr = re.sub(r'([0-9)])\s*平方', r'\1**2', expr)
|
|
341
|
+
expr = re.sub(r'([0-9)])\s*立方', r'\1**3', expr)
|
|
342
|
+
# 阶乘
|
|
343
|
+
expr = re.sub(r'(\d+|\([^()]*\))\s*!', r'factorial(\1)', expr)
|
|
344
|
+
# 中文运算符
|
|
345
|
+
expr = expr.replace('×', '*').replace('÷', '/')
|
|
346
|
+
expr = expr.replace('加上', '+').replace('加', '+')
|
|
347
|
+
expr = expr.replace('减去', '-').replace('减', '-')
|
|
348
|
+
expr = expr.replace('乘以', '*').replace('乘', '*')
|
|
349
|
+
expr = expr.replace('除以', '/').replace('除', '/')
|
|
350
|
+
# 从混杂中文的句子里抠出最长的合法算式串(吃掉“等于几/是多少”等问词)
|
|
351
|
+
cands = re.findall(r'[0-9A-Za-z_().+\-*/%,\s]+', expr)
|
|
352
|
+
if not cands:
|
|
353
|
+
return None
|
|
354
|
+
expr = max(cands, key=len).strip()
|
|
355
|
+
if not re.fullmatch(r'[0-9A-Za-z_().+\-*/%,\s]+', expr):
|
|
356
|
+
return None
|
|
357
|
+
val = eval(expr, {"__builtins__": {}}, _SAFE_MATH)
|
|
358
|
+
if isinstance(val, (int, float, complex)):
|
|
359
|
+
if isinstance(val, float) and val.is_integer():
|
|
360
|
+
val = int(val)
|
|
361
|
+
return f"{expr} = {val}"
|
|
362
|
+
return None
|
|
363
|
+
except Exception:
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
# ---------------- 角色扮演:后缀应用 ----------------
|
|
367
|
+
def _apply_role(self, text):
|
|
368
|
+
if not self.role:
|
|
369
|
+
return text
|
|
370
|
+
cfg = ROLES.get(self.role)
|
|
371
|
+
if not cfg:
|
|
372
|
+
return text
|
|
373
|
+
suf = cfg.get("suffix", "")
|
|
374
|
+
if not suf:
|
|
375
|
+
return text
|
|
376
|
+
out = re.sub(r'([。!?!?])', r'\1' + suf, text)
|
|
377
|
+
if not out.endswith(suf):
|
|
378
|
+
out = out + suf
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
# ---------------- 统一回复接口 ----------------
|
|
382
|
+
def ask(self, text, threshold=0.35):
|
|
383
|
+
text = (text or "").strip()
|
|
384
|
+
if not text:
|
|
385
|
+
return "在的,你说~"
|
|
386
|
+
# 退出角色
|
|
387
|
+
if any(w in text for w in ("退出角色", "退出扮演", "正常模式",
|
|
388
|
+
"关闭角色", "取消扮演", "变回正常")):
|
|
389
|
+
self.role = None
|
|
390
|
+
return "已退出角色扮演,恢复正常模式~"
|
|
391
|
+
# 进入角色(触发词本句当作指令,直接确认切换,不拿它当问题回答)
|
|
392
|
+
for name, cfg in ROLES.items():
|
|
393
|
+
if any(trig in text for trig in cfg["triggers"]):
|
|
394
|
+
self.role = name
|
|
395
|
+
return self._apply_role("喵~已经变成猫娘啦,以后每句话我都会带喵哦!")
|
|
396
|
+
# 寒暄
|
|
397
|
+
if self._is_greeting(text):
|
|
398
|
+
return self._apply_role(random.choice(_GREETING_REPLIES))
|
|
399
|
+
# 数学
|
|
400
|
+
r = self._calc(text)
|
|
401
|
+
if r is not None:
|
|
402
|
+
return self._apply_role(r)
|
|
403
|
+
# 时间
|
|
404
|
+
if self._is_time_query(text):
|
|
405
|
+
return self._apply_role(self._time_reply())
|
|
406
|
+
# 知识库检索
|
|
407
|
+
ans, score, q = self._retrieve(text, threshold)
|
|
408
|
+
if ans:
|
|
409
|
+
return self._apply_role(ans)
|
|
410
|
+
# 马尔可夫兜底
|
|
411
|
+
gen = self._generate(seed=text, max_len=40)
|
|
412
|
+
if gen and len(gen) >= 2:
|
|
413
|
+
return self._apply_role(gen)
|
|
414
|
+
return self._apply_role("这个我还没学会耶~你可以把答案写进语料库"
|
|
415
|
+
"(用 问题===答案 的格式)教我,或者换个说法试试。")
|
|
416
|
+
|
|
417
|
+
# ---------------- 持久化(训练结果存盘,启动秒开) ----------------
|
|
418
|
+
def save_model(self, path=None):
|
|
419
|
+
path = path or self.model_path
|
|
420
|
+
if not path:
|
|
421
|
+
return
|
|
422
|
+
data = {
|
|
423
|
+
"n": self.n,
|
|
424
|
+
"model": self.model,
|
|
425
|
+
"starters": self.starters,
|
|
426
|
+
"kb": self.kb,
|
|
427
|
+
}
|
|
428
|
+
tmp = path + ".tmp"
|
|
429
|
+
with open(tmp, "wb") as f:
|
|
430
|
+
pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL)
|
|
431
|
+
os.replace(tmp, path)
|
|
432
|
+
|
|
433
|
+
def load_model(self, path=None):
|
|
434
|
+
path = path or self.model_path
|
|
435
|
+
if not path or not os.path.exists(path):
|
|
436
|
+
return False
|
|
437
|
+
try:
|
|
438
|
+
with open(path, "rb") as f:
|
|
439
|
+
data = pickle.load(f)
|
|
440
|
+
self.n = data.get("n", self.n)
|
|
441
|
+
self.model = data.get("model", {})
|
|
442
|
+
self.starters = data.get("starters", [])
|
|
443
|
+
self.kb = data.get("kb", [])
|
|
444
|
+
self.trained = bool(self.model or self.kb)
|
|
445
|
+
return True
|
|
446
|
+
except Exception:
|
|
447
|
+
return False
|
|
448
|
+
|
|
449
|
+
def load_or_train(self, force=False):
|
|
450
|
+
if not force and self.load_model():
|
|
451
|
+
return "loaded"
|
|
452
|
+
self.load_corpus()
|
|
453
|
+
self.save_model()
|
|
454
|
+
return "trained"
|