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 ADDED
@@ -0,0 +1,9 @@
1
+ """doorcore — door-Core v2 本地 AI 引擎(可 pip 安装的公开库)"""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .core import DoorCore
6
+ from .api import application, get_ai
7
+ from .cli import main as cli_main
8
+
9
+ __all__ = ["DoorCore", "application", "get_ai", "cli_main", "__version__"]
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"
@@ -0,0 +1,3 @@
1
+ {
2
+ "n": 3
3
+ }