mcai-knowledge 0.2.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.
mcai/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """元意识知识资产引擎(MCAI Engine)。"""
2
+
3
+ __version__ = "0.2.0"
mcai/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """python -m mcai 入口。"""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
mcai/autopilot.py ADDED
@@ -0,0 +1,229 @@
1
+ """自动驾驶:让知识库在无人参与下保持「活着、准着、可被调用」。
2
+
3
+ 一轮循环做四件事:
4
+ 1. 检查语料/配置指纹,变了就重建;
5
+ 2. 记录变更日志(新增/删除/改写的术语),让每次变化可追溯;
6
+ 3. 健康自检(知识库、站点、服务端口、接口响应);
7
+ 4. 把状态写到 dist/status.json 与 data/status.json,供调用方判断是否健康。
8
+
9
+ 设计为「一次性执行」:由系统计划任务每 5 分钟调用一次,比常驻进程更抗崩溃。
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ import socket
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ import urllib.request
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+
23
+ from . import dashboard, knowledge, pipeline, quality, selftest, snapshot
24
+
25
+ DEFAULT_PORT = 8787
26
+
27
+
28
+ class Autopilot:
29
+ def __init__(self, root: str | Path, port: int = DEFAULT_PORT, ensure_server: bool = True):
30
+ self.root = Path(root).resolve()
31
+ self.port = port
32
+ self.ensure_server = ensure_server
33
+ self.data = self.root / "data"
34
+ self.data.mkdir(parents=True, exist_ok=True)
35
+
36
+ # ---------- 指纹 ----------
37
+ def fingerprint(self) -> str:
38
+ h = hashlib.sha256()
39
+ cfg_path = self.root / "config" / "corpus.json"
40
+ for path in sorted((self.root / "config").glob("*.json")):
41
+ h.update(path.name.encode("utf-8"))
42
+ h.update(path.read_bytes())
43
+ try:
44
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8-sig"))
45
+ except Exception: # noqa: BLE001
46
+ cfg = {}
47
+ for spec in cfg.get("corpus", []):
48
+ raw = Path(spec.get("path", ""))
49
+ path = raw if raw.is_absolute() else self.root / raw
50
+ if path.exists():
51
+ stat = path.stat()
52
+ h.update(f"{spec.get('id')}:{stat.st_size}:{int(stat.st_mtime)}".encode("utf-8"))
53
+ else:
54
+ h.update(f"{spec.get('id')}:missing".encode("utf-8"))
55
+ return h.hexdigest()
56
+
57
+ # ---------- 快照与差异 ----------
58
+ def _snapshot(self) -> dict:
59
+ path = self.data / "kb.json"
60
+ if not path.exists():
61
+ return {}
62
+ try:
63
+ kb = json.loads(path.read_text(encoding="utf-8"))
64
+ except json.JSONDecodeError:
65
+ return {}
66
+ return {
67
+ c["term"]: hashlib.md5(c.get("definition", "").encode("utf-8")).hexdigest()
68
+ for c in kb.get("concepts", [])
69
+ }
70
+
71
+ @staticmethod
72
+ def _diff(before: dict, after: dict) -> dict:
73
+ added = sorted(set(after) - set(before))
74
+ removed = sorted(set(before) - set(after))
75
+ changed = sorted(t for t in set(before) & set(after) if before[t] != after[t])
76
+ return {"added": added, "removed": removed, "changed": changed}
77
+
78
+ # ---------- 服务检查 ----------
79
+ def _port_open(self) -> bool:
80
+ try:
81
+ with socket.create_connection(("127.0.0.1", self.port), timeout=0.6):
82
+ return True
83
+ except OSError:
84
+ return False
85
+
86
+ def _server_healthy(self) -> bool:
87
+ try:
88
+ with urllib.request.urlopen(f"http://127.0.0.1:{self.port}/api/v1/health", timeout=2) as resp:
89
+ return resp.status == 200
90
+ except Exception: # noqa: BLE001
91
+ return False
92
+
93
+ def _start_server(self) -> bool:
94
+ if not self.ensure_server or self._port_open():
95
+ return False
96
+ flags = 0
97
+ if sys.platform == "win32":
98
+ flags = 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
99
+ try:
100
+ subprocess.Popen(
101
+ [sys.executable, "-m", "mcai", "serve", "--home", str(self.root), "--port", str(self.port)],
102
+ cwd=str(self.root),
103
+ stdin=subprocess.DEVNULL,
104
+ stdout=subprocess.DEVNULL,
105
+ stderr=subprocess.DEVNULL,
106
+ creationflags=flags,
107
+ )
108
+ for _ in range(10):
109
+ time.sleep(0.3)
110
+ if self._port_open():
111
+ return True
112
+ except Exception: # noqa: BLE001
113
+ return False
114
+ return False
115
+
116
+ # ---------- 主循环 ----------
117
+ def run_once(self) -> dict:
118
+ started = time.time()
119
+ state_path = self.data / "autopilot-state.json"
120
+ state = {}
121
+ if state_path.exists():
122
+ try:
123
+ state = json.loads(state_path.read_text(encoding="utf-8"))
124
+ except json.JSONDecodeError:
125
+ state = {}
126
+
127
+ before = self._snapshot()
128
+ fingerprint = self.fingerprint()
129
+ rebuilt = False
130
+ error = ""
131
+ result = {}
132
+
133
+ needs_build = (fingerprint != state.get("fingerprint")) or not (self.data / "kb.json").exists()
134
+ if needs_build:
135
+ try:
136
+ result = pipeline.rebuild(self.root)
137
+ rebuilt = True
138
+ except Exception as exc: # noqa: BLE001
139
+ error = f"{type(exc).__name__}: {exc}"
140
+
141
+ after = self._snapshot()
142
+ diff = self._diff(before, after) if rebuilt else {"added": [], "removed": [], "changed": []}
143
+
144
+ started_server = self._start_server()
145
+ server_ok = self._server_healthy()
146
+
147
+ kb = {}
148
+ try:
149
+ kb = knowledge.load(self.root).meta.get("stats", {})
150
+ except Exception: # noqa: BLE001
151
+ pass
152
+
153
+ try:
154
+ q = quality.inspect(self.root)
155
+ except Exception as exc: # noqa: BLE001
156
+ q = {"ok": False, "counts": {"error": 1, "warn": 0},
157
+ "errors": [{"detail": f"{type(exc).__name__}: {exc}"}]}
158
+ try:
159
+ t = selftest.run(self.root)
160
+ except Exception as exc: # noqa: BLE001
161
+ t = {"ok": False, "passed": 0, "total": 0, "failed": [f"{type(exc).__name__}: {exc}"]}
162
+
163
+ now = datetime.now().astimezone().isoformat(timespec="seconds")
164
+ status = {
165
+ "ok": (not error and bool(kb) and (server_ok if self.ensure_server else True)
166
+ and q.get("ok", False) and t.get("ok", False)),
167
+ "quality": {"ok": q.get("ok"), "errors": q.get("counts", {}).get("error", 0),
168
+ "warnings": q.get("counts", {}).get("warn", 0)},
169
+ "selftest": {"ok": t.get("ok"), "passed": t.get("passed"), "total": t.get("total"),
170
+ "failed": t.get("failed", [])},
171
+ "checked_at": now,
172
+ "built_at": result.get("built_at") or kb.get("built_at"),
173
+ "rebuilt": rebuilt,
174
+ "started_server": started_server,
175
+ "server_ok": server_ok,
176
+ "concepts": kb.get("concepts"),
177
+ "faqs": kb.get("faqs"),
178
+ "relations": kb.get("relations"),
179
+ "evidence": kb.get("evidence"),
180
+ "error": error,
181
+ "duration_ms": int((time.time() - started) * 1000),
182
+ }
183
+
184
+ # 写状态:data 里永久保留,dist 里给外部读(重建会清空 dist,所以最后写)
185
+ (self.data / "status.json").write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8")
186
+ dist = self.root / "dist"
187
+ if dist.exists():
188
+ (dist / "status.json").write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8")
189
+
190
+ # 变更日志
191
+ if rebuilt and before and (diff["added"] or diff["removed"] or diff["changed"]):
192
+ entry = {"ts": now, **diff, "concepts": status["concepts"], "faqs": status["faqs"]}
193
+ with (self.data / "changelog.jsonl").open("a", encoding="utf-8") as fh:
194
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
195
+
196
+ with (self.data / "autopilot.jsonl").open("a", encoding="utf-8") as fh:
197
+ fh.write(json.dumps(status, ensure_ascii=False) + "\n")
198
+
199
+ try:
200
+ dashboard.render(self.root)
201
+ except Exception: # noqa: BLE001 - 看板失败不影响巡检
202
+ pass
203
+
204
+ if rebuilt:
205
+ try:
206
+ snap = snapshot.create(self.root, note="自动快照:语料变更", keep=8)
207
+ status["snapshot"] = Path(snap["archive"]).name
208
+ except Exception: # noqa: BLE001 - 快照失败不影响巡检
209
+ pass
210
+ state["fingerprint"] = fingerprint
211
+ state["last_build"] = now
212
+ state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
213
+
214
+ return {"status": status, "diff": diff, "rebuilt": rebuilt}
215
+
216
+ def loop(self, interval: int = 60) -> None:
217
+ print(f"自动驾驶已启动(每 {interval} 秒检查一次,Ctrl+C 停止)")
218
+ while True:
219
+ try:
220
+ outcome = self.run_once()
221
+ s = outcome["status"]
222
+ flag = "重建" if outcome["rebuilt"] else "无变化"
223
+ print(f"[{s['checked_at']}] {flag} · {s['concepts']} 概念 · 服务 "
224
+ f"{'在线' if s['server_ok'] else '离线'} · {s['duration_ms']}ms")
225
+ except KeyboardInterrupt:
226
+ raise
227
+ except Exception as exc: # noqa: BLE001
228
+ print(f"[{datetime.now().isoformat(timespec='seconds')}] 异常:{type(exc).__name__}: {exc}")
229
+ time.sleep(max(5, interval))