acmer-coach 1.0.0
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.
- package/LICENSE +28 -0
- package/README.md +258 -0
- package/assets/extension/luogu/README.md +104 -0
- package/assets/extension/luogu/content.js +137 -0
- package/assets/extension/luogu/manifest.json +22 -0
- package/assets/knowledge/DEPENDS.yaml +391 -0
- package/assets/knowledge/LICENSE +41 -0
- package/assets/knowledge/MAP.yaml +2540 -0
- package/assets/knowledge/NODE_ENTRY.yaml +465 -0
- package/assets/knowledge/NODE_META.yaml +457 -0
- package/assets/knowledge/skilltree.html +1702 -0
- package/assets/oiwiki/LICENSE +49 -0
- package/assets/oiwiki/chunks.jsonl +2177 -0
- package/assets/oiwiki/node-pages.json +1208 -0
- package/assets/pool/LICENSE +83 -0
- package/assets/pool/problems.jsonl.gz +0 -0
- package/assets/rules/coach-rules.md +425 -0
- package/build-node-meta.mjs +98 -0
- package/coach/analyze_map.py +138 -0
- package/coach/build_map.py +230 -0
- package/coach/build_node_pages.py +105 -0
- package/coach/check_page_syntax.mjs +48 -0
- package/coach/coach-metrics.py +313 -0
- package/coach/export_oiwiki_chunks.py +100 -0
- package/coach/export_problem_pool.py +64 -0
- package/coach/extract_oiwiki.py +110 -0
- package/coach/oiwiki_tree.json +2987 -0
- package/coach/render_map.py +226 -0
- package/coach/render_skilltree.py +197 -0
- package/coach/serve_map.py +181 -0
- package/coach/skilltree_template.html +1702 -0
- package/coach/validate_map.py +184 -0
- package/coach/verify_drag.mjs +51 -0
- package/coach/verify_interact.mjs +82 -0
- package/coach/verify_serve.py +138 -0
- package/cordis.patch.yml +10 -0
- package/dev/parity-check.mjs +169 -0
- package/dev/run-all.sh +70 -0
- package/dev/setup-check.mjs +191 -0
- package/dev/w1-check.mjs +170 -0
- package/dev/wiki-check.mjs +100 -0
- package/dev.sh +142 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +325 -0
- package/index.js +5820 -0
- package/lib/client.js +209 -0
- package/lib/setup.js +455 -0
- package/package.json +33 -0
- package/progress-cli.mjs +67 -0
- package/verify-shape.mjs +315 -0
- package/verify.mjs +3535 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
validate_map.py — MAP.yaml 结构校验(自动跑,不靠人眼)
|
|
4
|
+
|
|
5
|
+
检查四件事:
|
|
6
|
+
1. 环 —— A 依赖 B、B 又依赖 A(拓扑序必须存在)
|
|
7
|
+
2. 孤儿边 —— depends 引用了一个不存在的 id
|
|
8
|
+
3. 自环 / 重复 id
|
|
9
|
+
4. 统计:入度 0 的入口节点、拓扑序(第 1 遍的产出)
|
|
10
|
+
|
|
11
|
+
用法: python validate_map.py [MAP.yaml 路径]
|
|
12
|
+
默认 ~/.dsh/knowledge/MAP.yaml
|
|
13
|
+
退出码: 0 通过 / 1 有错
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
for _s in (sys.stdout, sys.stderr):
|
|
19
|
+
if _s and hasattr(_s, "reconfigure"):
|
|
20
|
+
_s.reconfigure(encoding="utf-8", errors="replace")
|
|
21
|
+
|
|
22
|
+
DEFAULT = os.path.join(os.path.expanduser("~"), ".dsh", "knowledge", "MAP.yaml")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load(path):
|
|
26
|
+
"""读 YAML。优先用 pyyaml;没有则用极简解析(只认本文件格式)。"""
|
|
27
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
28
|
+
text = f.read()
|
|
29
|
+
try:
|
|
30
|
+
import yaml
|
|
31
|
+
return yaml.safe_load(text)
|
|
32
|
+
except ImportError:
|
|
33
|
+
pass
|
|
34
|
+
# 极简 fallback:逐行扫 nodes 段的 id / depends
|
|
35
|
+
nodes, cur = [], None
|
|
36
|
+
in_nodes = False
|
|
37
|
+
for line in text.splitlines():
|
|
38
|
+
if line.startswith("nodes:"):
|
|
39
|
+
in_nodes = True
|
|
40
|
+
continue
|
|
41
|
+
if in_nodes and line and not line[0].isspace():
|
|
42
|
+
break # 离开 nodes 段
|
|
43
|
+
s = line.strip()
|
|
44
|
+
if s.startswith("- id:"):
|
|
45
|
+
cur = {"id": s.split(":", 1)[1].strip(), "depends": []}
|
|
46
|
+
nodes.append(cur)
|
|
47
|
+
elif s.startswith("depends:") and cur is not None:
|
|
48
|
+
raw = s.split(":", 1)[1].strip()
|
|
49
|
+
cur["depends"] = [x.strip() for x in raw.strip("[]").split(",") if x.strip()]
|
|
50
|
+
return {"nodes": nodes}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main():
|
|
54
|
+
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
|
|
55
|
+
if not os.path.exists(path):
|
|
56
|
+
print(f"[ERR] 找不到 {path}")
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
doc = load(path)
|
|
60
|
+
nodes = doc.get("nodes") or []
|
|
61
|
+
if not nodes:
|
|
62
|
+
print("[ERR] 没解析出任何节点")
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
ids = [n["id"] for n in nodes]
|
|
66
|
+
errors = []
|
|
67
|
+
|
|
68
|
+
# 1. 重复 id
|
|
69
|
+
dup = {i for i in ids if ids.count(i) > 1}
|
|
70
|
+
if dup:
|
|
71
|
+
errors.append(f"重复 id: {sorted(dup)}")
|
|
72
|
+
|
|
73
|
+
idset = set(ids)
|
|
74
|
+
|
|
75
|
+
# 2. 孤儿边 + 自环
|
|
76
|
+
# ⚠️ 只看 depends,**绝不要把 parent 算进来**。
|
|
77
|
+
# parent 是「分类归属」——「树上问题」底下挂着 12 个并列的结构,
|
|
78
|
+
# 「数论」底下挂着 32 个,它们不是前置。旧版把两者合并校验,
|
|
79
|
+
# 于是报出「依赖边 315 条、入口 104 个」,看着像个漂亮的 DAG,
|
|
80
|
+
# 其实那 315 里有 177 条是分类关系,图被撑起来了而已。
|
|
81
|
+
def deps_of(n):
|
|
82
|
+
return list(n.get("depends") or [])
|
|
83
|
+
|
|
84
|
+
for n in nodes:
|
|
85
|
+
for d in deps_of(n):
|
|
86
|
+
if d == n["id"]:
|
|
87
|
+
errors.append(f"自环: {n['id']} 依赖自己")
|
|
88
|
+
elif d not in idset:
|
|
89
|
+
errors.append(f"孤儿边: {n['id']} 依赖不存在的 '{d}'")
|
|
90
|
+
|
|
91
|
+
# 3. 环检测(DFS 三色标记)
|
|
92
|
+
graph = {n["id"]: [d for d in deps_of(n) if d in idset] for n in nodes}
|
|
93
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
94
|
+
color = {i: WHITE for i in ids}
|
|
95
|
+
cycle = []
|
|
96
|
+
|
|
97
|
+
def dfs(u, stack):
|
|
98
|
+
color[u] = GRAY
|
|
99
|
+
stack.append(u)
|
|
100
|
+
for v in graph[u]:
|
|
101
|
+
if color[v] == GRAY:
|
|
102
|
+
i = stack.index(v)
|
|
103
|
+
cycle.append(" → ".join(stack[i:] + [v]))
|
|
104
|
+
return True
|
|
105
|
+
if color[v] == WHITE and dfs(v, stack):
|
|
106
|
+
return True
|
|
107
|
+
stack.pop()
|
|
108
|
+
color[u] = BLACK
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
for i in ids:
|
|
112
|
+
if color[i] == WHITE and dfs(i, []):
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
if cycle:
|
|
116
|
+
errors.append(f"存在环: {cycle[0]}")
|
|
117
|
+
|
|
118
|
+
# ── 报告 ──
|
|
119
|
+
print("=" * 60)
|
|
120
|
+
print(f"MAP 校验: {path}")
|
|
121
|
+
print("=" * 60)
|
|
122
|
+
print(f"节点总数 {len(nodes)}")
|
|
123
|
+
|
|
124
|
+
domains = {}
|
|
125
|
+
for n in nodes:
|
|
126
|
+
domains.setdefault(n.get("domain", "?"), []).append(n["id"])
|
|
127
|
+
print(f"学科数 {len(domains)}")
|
|
128
|
+
for d, ns in sorted(domains.items(), key=lambda kv: -len(kv[1])):
|
|
129
|
+
print(f" {d:8s} {len(ns):3d} 节")
|
|
130
|
+
|
|
131
|
+
unpaved = [n["id"] for n in nodes if n.get("depends") is None]
|
|
132
|
+
print(f"前置边总数 {sum(len(v) for v in graph.values())}")
|
|
133
|
+
print(f"已铺前置 {len(ids) - len(unpaved)} / {len(ids)}"
|
|
134
|
+
+ (f" ⚠️ {len(unpaved)} 个还没铺(它们的入度 0 是「不知道」,不是「根」)"
|
|
135
|
+
if unpaved else ""))
|
|
136
|
+
|
|
137
|
+
entries = [i for i in ids if not graph[i]]
|
|
138
|
+
print(f"入口节点(入度0) {len(entries)}"
|
|
139
|
+
+ (f" ← 含 {len(unpaved)} 个未铺,别当成根" if unpaved else ""))
|
|
140
|
+
for e in entries[:40]:
|
|
141
|
+
print(f" {e}")
|
|
142
|
+
if len(entries) > 40:
|
|
143
|
+
print(f" …(还有 {len(entries) - 40} 个)")
|
|
144
|
+
|
|
145
|
+
# 拓扑序(第 1 遍的产出 —— 学习顺序的候选)
|
|
146
|
+
# 注意边方向:graph[u] = u 的**依赖**(u 指向它的前置)。
|
|
147
|
+
# 学习顺序要求「前置先出」,所以入度 = 该节点**未满足的依赖数** = len(graph[u]),
|
|
148
|
+
# 出队时递减的是「依赖 u 的那些节点」的入度。
|
|
149
|
+
# (第一版写反了:按「有多少人依赖我」计入度,结果把最难的节点排在了最前面。)
|
|
150
|
+
dependents = {i: [] for i in ids}
|
|
151
|
+
for u, vs in graph.items():
|
|
152
|
+
for v in vs:
|
|
153
|
+
dependents[v].append(u)
|
|
154
|
+
indeg = {i: len(graph[i]) for i in ids}
|
|
155
|
+
ready = sorted([i for i in ids if indeg[i] == 0])
|
|
156
|
+
topo = []
|
|
157
|
+
while ready:
|
|
158
|
+
u = ready.pop(0)
|
|
159
|
+
topo.append(u)
|
|
160
|
+
for w in dependents[u]:
|
|
161
|
+
indeg[w] -= 1
|
|
162
|
+
if indeg[w] == 0:
|
|
163
|
+
ready.append(w)
|
|
164
|
+
ready.sort()
|
|
165
|
+
print(f"拓扑序长度 {len(topo)} / {len(ids)}"
|
|
166
|
+
+ ("" if len(topo) == len(ids) else " ⚠️ 少于节点数 = 有环"))
|
|
167
|
+
|
|
168
|
+
print()
|
|
169
|
+
if errors:
|
|
170
|
+
print(f"❌ 发现 {len(errors)} 个问题:")
|
|
171
|
+
for e in errors:
|
|
172
|
+
print(f" - {e}")
|
|
173
|
+
return 1
|
|
174
|
+
|
|
175
|
+
print("✅ 结构合法:无环、无孤儿边、无重复 id")
|
|
176
|
+
print()
|
|
177
|
+
print("拓扑序(前 20,仅作参考 —— 真正的学习顺序还要叠权重和缺口):")
|
|
178
|
+
for i, nid in enumerate(topo[:20], 1):
|
|
179
|
+
print(f" {i:2d}. {nid}")
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
sys.exit(main())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// 用 CDP 原生鼠标事件做一次真实拖拽,再看有没有选出一堆文字。
|
|
2
|
+
// 合成事件(dispatchEvent)不会触发浏览器的原生选区,只有 Input.dispatchMouseEvent 才算数。
|
|
3
|
+
const BASE = 'http://127.0.0.1:9222';
|
|
4
|
+
|
|
5
|
+
const r = await (await fetch(`${BASE}/json/list`)).json();
|
|
6
|
+
const page = r.find((t) => t.type === 'page' && /skilltree/.test(t.url));
|
|
7
|
+
if (!page) { console.log('找不到 skilltree 页面'); process.exit(1); }
|
|
8
|
+
|
|
9
|
+
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
10
|
+
let id = 0;
|
|
11
|
+
const pend = new Map();
|
|
12
|
+
const send = (method, params = {}, sessionId) =>
|
|
13
|
+
new Promise((res) => { const i = ++id; pend.set(i, res);
|
|
14
|
+
ws.send(JSON.stringify({ id: i, method, params, ...(sessionId ? { sessionId } : {}) })); });
|
|
15
|
+
|
|
16
|
+
ws.addEventListener('message', (ev) => {
|
|
17
|
+
const m = JSON.parse(ev.data);
|
|
18
|
+
if (m.id && pend.has(m.id)) { pend.get(m.id)(m.result); pend.delete(m.id); }
|
|
19
|
+
});
|
|
20
|
+
await new Promise((res) => ws.addEventListener('open', res));
|
|
21
|
+
|
|
22
|
+
const { sessionId } = await send('Target.attachToTarget', { targetId: page.id, flatten: true });
|
|
23
|
+
|
|
24
|
+
const mouse = (type, x, y, extra = {}) =>
|
|
25
|
+
send('Input.dispatchMouseEvent',
|
|
26
|
+
{ type, x, y, button: 'left', buttons: type === 'mouseReleased' ? 0 : 1, clickCount: 1, ...extra },
|
|
27
|
+
sessionId);
|
|
28
|
+
|
|
29
|
+
const evalIn = async (expr) => {
|
|
30
|
+
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true }, sessionId);
|
|
31
|
+
return r?.result?.value;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// 先看看拖之前图上有没有残留选区
|
|
35
|
+
await evalIn('getSelection().removeAllRanges()');
|
|
36
|
+
|
|
37
|
+
await mouse('mousePressed', 800, 600);
|
|
38
|
+
for (let i = 1; i <= 12; i++) await mouse('mouseMoved', 800 - i * 25, 600 - i * 22); // 真实拖拽轨迹
|
|
39
|
+
await mouse('mouseReleased', 500, 336);
|
|
40
|
+
|
|
41
|
+
const sel = await evalIn('getSelection().toString().slice(0,120)');
|
|
42
|
+
const selLen = await evalIn('getSelection().rangeCount ? getSelection().getRangeAt(0).toString().length : 0');
|
|
43
|
+
const moved = await evalIn('document.querySelector("svg").style.transform');
|
|
44
|
+
|
|
45
|
+
console.log(JSON.stringify({
|
|
46
|
+
选中文字长度: selLen,
|
|
47
|
+
选中内容: sel,
|
|
48
|
+
是否平移: /translate\(/.test(moved || '') && !/translate\(28px/.test(moved || ''),
|
|
49
|
+
当前transform: moved,
|
|
50
|
+
}, null, 2));
|
|
51
|
+
ws.close();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// 用 CDP 原生鼠标事件验交互:
|
|
2
|
+
// 1) 悬停节点 → 不该亮路径
|
|
3
|
+
// 2) 点击节点 → 该亮路径
|
|
4
|
+
// 3) 点空白 → 该收起
|
|
5
|
+
// 4) 拖一把 → 面板不该被顺手关掉
|
|
6
|
+
// 合成事件测不出这些(不会走原生命中测试),必须用 Input.dispatchMouseEvent。
|
|
7
|
+
const BASE = 'http://127.0.0.1:9222';
|
|
8
|
+
const NODE = process.argv[2] || '树链剖分';
|
|
9
|
+
|
|
10
|
+
const list = await (await fetch(`${BASE}/json/list`)).json();
|
|
11
|
+
const page = list.find((t) => t.type === 'page' && /skilltree/.test(t.url));
|
|
12
|
+
if (!page) { console.log('找不到 skilltree 页面'); process.exit(1); }
|
|
13
|
+
|
|
14
|
+
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
15
|
+
let id = 0;
|
|
16
|
+
const pend = new Map();
|
|
17
|
+
const send = (method, params = {}, sessionId) =>
|
|
18
|
+
new Promise((res) => { const i = ++id; pend.set(i, res);
|
|
19
|
+
ws.send(JSON.stringify({ id: i, method, params, ...(sessionId ? { sessionId } : {}) })); });
|
|
20
|
+
ws.addEventListener('message', (ev) => {
|
|
21
|
+
const m = JSON.parse(ev.data);
|
|
22
|
+
if (m.id && pend.has(m.id)) { pend.get(m.id)(m.result); pend.delete(m.id); }
|
|
23
|
+
});
|
|
24
|
+
await new Promise((res) => ws.addEventListener('open', res));
|
|
25
|
+
const { sessionId } = await send('Target.attachToTarget', { targetId: page.id, flatten: true });
|
|
26
|
+
|
|
27
|
+
const evalIn = async (expr) =>
|
|
28
|
+
(await send('Runtime.evaluate', { expression: expr, returnByValue: true }, sessionId))?.result?.value;
|
|
29
|
+
const mouse = (type, x, y, extra = {}) =>
|
|
30
|
+
send('Input.dispatchMouseEvent',
|
|
31
|
+
{ type, x, y, button: 'left', buttons: type === 'mouseReleased' ? 0 : 1, clickCount: 1, ...extra },
|
|
32
|
+
sessionId);
|
|
33
|
+
|
|
34
|
+
// 数「看得见的」高亮 —— 光数类名会把 display:none 的也算进去
|
|
35
|
+
const snap = () => evalIn(`JSON.stringify({
|
|
36
|
+
hotNodes: [...document.querySelectorAll('.node.hot')].filter(e=>e.style.display!=='none').length,
|
|
37
|
+
hotEdges: [...document.querySelectorAll('path.edge.hot')].filter(e=>e.style.display!=='none').length,
|
|
38
|
+
staleEdges: [...document.querySelectorAll('path.edge.hot')].filter(e=>e.style.display==='none').length,
|
|
39
|
+
panel: document.querySelector('#panel').classList.contains('on'),
|
|
40
|
+
})`).then(JSON.parse);
|
|
41
|
+
|
|
42
|
+
const at = async (id) => JSON.parse(await evalIn(
|
|
43
|
+
`(() => { const g=[...document.querySelectorAll('.node')].find(e=>e.dataset.id===${JSON.stringify(id)});
|
|
44
|
+
if(!g) return 'null'; const r=g.getBoundingClientRect();
|
|
45
|
+
return JSON.stringify({x:r.x+r.width/2, y:r.y+r.height/2}); })()`));
|
|
46
|
+
|
|
47
|
+
const pos = await at(NODE);
|
|
48
|
+
if (!pos) { console.log(`节点 ${NODE} 不在视口里`); process.exit(1); }
|
|
49
|
+
const out = {};
|
|
50
|
+
|
|
51
|
+
// 1) 悬停
|
|
52
|
+
await mouse('mouseMoved', pos.x, pos.y, { button: 'none', buttons: 0 });
|
|
53
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
54
|
+
out['1_悬停'] = await snap();
|
|
55
|
+
|
|
56
|
+
// 2) 点击
|
|
57
|
+
await mouse('mousePressed', pos.x, pos.y);
|
|
58
|
+
await mouse('mouseReleased', pos.x, pos.y);
|
|
59
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
60
|
+
out['2_点击后'] = await snap();
|
|
61
|
+
|
|
62
|
+
// 3) 拖一把(面板应保留)
|
|
63
|
+
await mouse('mousePressed', 500, 800);
|
|
64
|
+
for (let i = 1; i <= 8; i++) await mouse('mouseMoved', 500 + i * 12, 800 + i * 4);
|
|
65
|
+
await mouse('mouseReleased', 596, 832);
|
|
66
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
67
|
+
out['3_拖拽后'] = await snap();
|
|
68
|
+
|
|
69
|
+
// 4) 点空白(面板应收起)
|
|
70
|
+
await mouse('mousePressed', 300, 900);
|
|
71
|
+
await mouse('mouseReleased', 300, 900);
|
|
72
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
73
|
+
out['4_点空白后'] = await snap();
|
|
74
|
+
|
|
75
|
+
out['判定'] = {
|
|
76
|
+
'悬停不亮': out['1_悬停'].hotEdges === 0 && out['1_悬停'].hotNodes === 0,
|
|
77
|
+
'点击才亮': out['2_点击后'].hotEdges > 0 && out['2_点击后'].panel,
|
|
78
|
+
'拖拽不误关': out['3_拖拽后'].panel === true,
|
|
79
|
+
'点空白收起': out['4_点空白后'].panel === false && out['4_点空白后'].hotNodes === 0,
|
|
80
|
+
};
|
|
81
|
+
console.log(JSON.stringify(out, null, 2));
|
|
82
|
+
ws.close();
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
verify_serve.py — 验收 serve_map.py 的进度接口(/api/progress)
|
|
4
|
+
|
|
5
|
+
在**临时目录**跑(COACH_DATA_DIR 指过去),绝不碰真的 PROGRESS.yaml。
|
|
6
|
+
CLI 子进程继承这个环境变量,所以整条链路(HTTP → Python → node → 文件)
|
|
7
|
+
都落在临时目录里。
|
|
8
|
+
|
|
9
|
+
用法: python verify_serve.py (不需要先起 serve_map.py,本脚本自己起)
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import threading
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from http.server import HTTPServer
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
23
|
+
import serve_map # noqa: E402
|
|
24
|
+
|
|
25
|
+
for _s in (sys.stdout, sys.stderr):
|
|
26
|
+
if _s and hasattr(_s, "reconfigure"):
|
|
27
|
+
_s.reconfigure(encoding="utf-8", errors="replace")
|
|
28
|
+
|
|
29
|
+
fails = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check(label, cond, extra=""):
|
|
33
|
+
print(f"{' OK ' if cond else ' FAIL'} {label}" + (f" → {extra}" if extra else ""))
|
|
34
|
+
if not cond:
|
|
35
|
+
fails.append(label)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
MINI_MAP = """meta: { version: 9, node_count: 3 }
|
|
39
|
+
nodes:
|
|
40
|
+
- id: A
|
|
41
|
+
name: A
|
|
42
|
+
domain: 测试
|
|
43
|
+
depends: []
|
|
44
|
+
- id: B
|
|
45
|
+
name: B
|
|
46
|
+
domain: 测试
|
|
47
|
+
depends: [A]
|
|
48
|
+
- id: C
|
|
49
|
+
name: C
|
|
50
|
+
domain: 测试
|
|
51
|
+
depends: [A]
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
tmp = tempfile.mkdtemp(prefix="coach-serve-")
|
|
55
|
+
Path(tmp, "MAP.yaml").write_text(MINI_MAP, encoding="utf-8")
|
|
56
|
+
Path(tmp, "skilltree.html").write_text("<html>stub</html>", encoding="utf-8")
|
|
57
|
+
os.environ["COACH_DATA_DIR"] = tmp
|
|
58
|
+
|
|
59
|
+
# 起服务(换掉目录和端口,别抢真的 8770,也别弹浏览器)
|
|
60
|
+
serve_map.ROOT = tmp
|
|
61
|
+
serve_map.PORT = 8791
|
|
62
|
+
PORT = 8791
|
|
63
|
+
|
|
64
|
+
httpd = HTTPServer(("127.0.0.1", PORT), lambda *a, **k: serve_map.Handler(
|
|
65
|
+
*a, directory=tmp, **k))
|
|
66
|
+
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
67
|
+
BASE = f"http://127.0.0.1:{PORT}/api/progress"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get():
|
|
71
|
+
with urllib.request.urlopen(BASE, timeout=10) as r:
|
|
72
|
+
return json.loads(r.read().decode("utf-8"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def post(payload):
|
|
76
|
+
req = urllib.request.Request(
|
|
77
|
+
BASE, data=json.dumps(payload).encode("utf-8"),
|
|
78
|
+
headers={"Content-Type": "application/json"}, method="POST")
|
|
79
|
+
try:
|
|
80
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
81
|
+
return r.status, json.loads(r.read().decode("utf-8"))
|
|
82
|
+
except urllib.error.HTTPError as e:
|
|
83
|
+
return e.code, json.loads(e.read().decode("utf-8"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
print("── 1. 接口通不通 ──")
|
|
87
|
+
p = get()
|
|
88
|
+
check("GET 返回空进度", p.get("ok") is True and p.get("cursor") == "", json.dumps(p, ensure_ascii=False))
|
|
89
|
+
|
|
90
|
+
st, r1 = post({"op": "mark", "node": "A", "status": "learned"})
|
|
91
|
+
check("POST 标记学过", st == 200 and r1.get("ok") is True, json.dumps(r1, ensure_ascii=False))
|
|
92
|
+
# ★ 闸门:verified 手标必须被挡住(自评 ≠ 检测结论)
|
|
93
|
+
st, rv = post({"op": "mark", "node": "A", "status": "verified"})
|
|
94
|
+
check("★ 手标 verified → 400(已验证只能靠检测)",
|
|
95
|
+
st == 400 and rv.get("ok") is False, json.dumps(rv, ensure_ascii=False))
|
|
96
|
+
st, r2 = post({"op": "mark", "node": "B", "status": "studying"})
|
|
97
|
+
check("POST 标记在学", st == 200 and r2.get("ok") is True)
|
|
98
|
+
|
|
99
|
+
p = get()
|
|
100
|
+
check("★ 再 GET 读得到刚写的(不是各存一份)",
|
|
101
|
+
p["nodes"].get("A", {}).get("status") == "learned"
|
|
102
|
+
and p["nodes"].get("B", {}).get("status") == "studying",
|
|
103
|
+
json.dumps(p["nodes"], ensure_ascii=False))
|
|
104
|
+
|
|
105
|
+
print("── 2. 游标 ──")
|
|
106
|
+
st, r3 = post({"op": "cursor", "node": "A"})
|
|
107
|
+
check("POST 设游标", st == 200 and r3.get("cursor") == "A")
|
|
108
|
+
check("GET 读得到游标", get()["cursor"] == "A")
|
|
109
|
+
|
|
110
|
+
print("── 3. 该挡的要挡住 ──")
|
|
111
|
+
st, r4 = post({"op": "mark", "node": "不存在的节点", "status": "learned"})
|
|
112
|
+
check("不存在的节点 → 400 而不是静默成功", st == 400 and r4.get("ok") is False,
|
|
113
|
+
json.dumps(r4, ensure_ascii=False))
|
|
114
|
+
st, r5 = post({"op": "mark", "node": "A", "status": "乱写的状态"})
|
|
115
|
+
check("非法状态 → 400", st == 400 and r5.get("ok") is False)
|
|
116
|
+
st, r6 = post({"op": "瞎写", "node": "A"})
|
|
117
|
+
check("未知 op → 400", st == 400 and r6.get("ok") is False)
|
|
118
|
+
|
|
119
|
+
print("── 4. 退回未学 ──")
|
|
120
|
+
st, _ = post({"op": "mark", "node": "B", "status": "none"})
|
|
121
|
+
check("none 把条目删掉(回到未学)", "B" not in get()["nodes"], json.dumps(get()["nodes"], ensure_ascii=False))
|
|
122
|
+
|
|
123
|
+
print("── 5. 关键:文件里是真的落盘了 ──")
|
|
124
|
+
raw = Path(tmp, "PROGRESS.yaml").read_text(encoding="utf-8")
|
|
125
|
+
check("PROGRESS.yaml 真有内容", "A" in raw and "learned" in raw)
|
|
126
|
+
check("文件头还在(没被 YAML dump 冲掉)", "唯一真相源" in raw,
|
|
127
|
+
raw.splitlines()[1] if raw else "(空)")
|
|
128
|
+
|
|
129
|
+
httpd.shutdown()
|
|
130
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
131
|
+
|
|
132
|
+
print()
|
|
133
|
+
if fails:
|
|
134
|
+
print(f"✗ {len(fails)} 条没过:")
|
|
135
|
+
for f in fails:
|
|
136
|
+
print(f" · {f}")
|
|
137
|
+
sys.exit(1)
|
|
138
|
+
print("✓ 全部通过")
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# acmer-coach 组合层(bundle 形态)。
|
|
2
|
+
#
|
|
3
|
+
# ⚠️ 两条路**不能并存**:这份 insert 和 profile 的 cordis.patch.yml 里再来
|
|
4
|
+
# 一条 insert,插件就会被挂两次。profile 那边现在是空的(留了说明)。
|
|
5
|
+
#
|
|
6
|
+
# ⚠️ id 必须和 index.js 导出的 `name` 逐字相同,name 必须和**包名**相同 ——
|
|
7
|
+
# 对不上不会报错,工具会**静默不注册**,界面上完全看不出区别。
|
|
8
|
+
- insert:
|
|
9
|
+
- id: acmer-coach
|
|
10
|
+
name: 'acmer-coach'
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// 保真度审计:开源版和线上那份,喂**同样的输入**,逐条比输出。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有这个:改了一堆东西(解绑路径、加两个工具、清注释、改名字),
|
|
4
|
+
// 单测全绿只能证明"新的这份自己跑得通",**证明不了"它和原来那只是同一只"**。
|
|
5
|
+
// 而用户要的正是后者。
|
|
6
|
+
//
|
|
7
|
+
// 做法:准备两份**逐字节相同**的临时数据目录,各喂给一个版本,
|
|
8
|
+
// 跑同一串调用,把返回值拉平了对比。允许的差异只有三处,都写在下面。
|
|
9
|
+
//
|
|
10
|
+
// 跑法:node dev/parity-check.mjs
|
|
11
|
+
import { mkdtempSync, writeFileSync, readFileSync, cpSync, existsSync, rmSync } from 'node:fs'
|
|
12
|
+
import { tmpdir, homedir } from 'node:os'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
const LIVE = process.env.COACH_LIVE_INDEX
|
|
16
|
+
|| join(homedir(), '.dsh', 'profiles', 'web', 'node_modules', 'dsh-coach', 'index.js')
|
|
17
|
+
|
|
18
|
+
let pass = 0, fail = 0, diff = 0
|
|
19
|
+
const check = (n, ok, d = '') => { console.log(`${ok ? ' ✓' : ' ✗'} ${n}${d ? ` — ${d}` : ''}`); ok ? pass++ : fail++ }
|
|
20
|
+
|
|
21
|
+
if (!existsSync(LIVE)) {
|
|
22
|
+
console.log(`✗ 找不到线上那份:${LIVE}`)
|
|
23
|
+
console.log(' (设 COACH_LIVE_INDEX 指到它的 index.js)')
|
|
24
|
+
process.exit(1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── 造两份逐字节相同的夹具数据目录 ──────────────────────────────────
|
|
28
|
+
// 两边各一份而不是共用一份:工具会**写**(coach_assign 落盘),
|
|
29
|
+
// 共用的话后跑的那个看到的是先跑的那个改过的状态,比出来的差异是假的。
|
|
30
|
+
const base = mkdtempSync(join(tmpdir(), 'parity-'))
|
|
31
|
+
const ASSETS = join(import.meta.dirname, '..', 'assets', 'knowledge')
|
|
32
|
+
const PROG = `version: 1
|
|
33
|
+
cursor: 动态规划基础
|
|
34
|
+
updated: 2000-01-01
|
|
35
|
+
nodes:
|
|
36
|
+
区间 DP:
|
|
37
|
+
status: studying
|
|
38
|
+
at: 2000-01-01
|
|
39
|
+
记忆化搜索:
|
|
40
|
+
status: learned
|
|
41
|
+
at: 2000-01-01
|
|
42
|
+
`
|
|
43
|
+
const SCHED = `version: 1
|
|
44
|
+
updated: "2000-01-01"
|
|
45
|
+
days: {}
|
|
46
|
+
`
|
|
47
|
+
for (const d of ['A', 'B']) {
|
|
48
|
+
const dir = join(base, d)
|
|
49
|
+
cpSync(ASSETS, dir, { recursive: true })
|
|
50
|
+
writeFileSync(join(dir, 'PROGRESS.yaml'), PROG)
|
|
51
|
+
writeFileSync(join(dir, 'SCHEDULE.yaml'), SCHED)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const mockCtx = (reg) => ({ tools: { register: (t) => reg.push(t) }, inject: () => {}, on: () => {}, get: () => undefined })
|
|
55
|
+
|
|
56
|
+
async function loadCopy(indexPath, dataDir, tag) {
|
|
57
|
+
process.env.COACH_DATA_DIR = dataDir
|
|
58
|
+
process.env.COACH_DB = join(dataDir, 'no-db.sqlite') // 两边都没有库 → 走降级路径
|
|
59
|
+
const mod = await import(`file:///${indexPath.replace(/\\/g, '/')}?parity=${tag}`)
|
|
60
|
+
const reg = []
|
|
61
|
+
mod.apply(mockCtx(reg))
|
|
62
|
+
return { mod, byName: new Map(reg.map((t) => [t.name, t])) }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const A = await loadCopy(join(import.meta.dirname, '..', 'index.js'), join(base, 'A'), 'oss')
|
|
66
|
+
const B = await loadCopy(LIVE, join(base, 'B'), 'live')
|
|
67
|
+
|
|
68
|
+
console.log('── 1. 工具清单 ──')
|
|
69
|
+
const namesA = [...A.byName.keys()].sort()
|
|
70
|
+
const namesB = [...B.byName.keys()].sort()
|
|
71
|
+
const onlyB = namesB.filter((n) => !namesA.includes(n))
|
|
72
|
+
const onlyA = namesA.filter((n) => !namesB.includes(n))
|
|
73
|
+
check('开源版没有丢掉任何原有工具', onlyB.length === 0, onlyB.join(',') || '(没丢)')
|
|
74
|
+
// 新增的只能是「搭环境 / 拉数据 / 查知识」这三类 —— 它们补的正是
|
|
75
|
+
// 开源版缺的那些基础设施(原版靠本地已有的库和 skill,别人没有)。
|
|
76
|
+
const EXPECTED_NEW = new Set(['coach_setup', 'coach_wiki', 'coach_import'])
|
|
77
|
+
check('新增的都在预期内(搭建 / 检索 / 导入)',
|
|
78
|
+
onlyA.every((n) => EXPECTED_NEW.has(n)), onlyA.join(',') || '(无新增)')
|
|
79
|
+
check('原有的 17 个行为没变(下面逐条比)', namesB.length === 17, `${namesB.length} 个`)
|
|
80
|
+
console.log(` 线上 ${namesB.length} 个 / 开源 ${namesA.length} 个(+${onlyA.join(', ') || '无'})`)
|
|
81
|
+
|
|
82
|
+
console.log('\n── 2. 共有工具:同样的输入 → 同样的输出 ──')
|
|
83
|
+
// 每条:[工具, 参数, 说明]
|
|
84
|
+
const CALLS = [
|
|
85
|
+
['coach_next', { cursor: '动态规划基础' }, '游标的下层候选'],
|
|
86
|
+
['coach_next', { cursor: '并不存在的知识点' }, '打错字'],
|
|
87
|
+
['coach_status', {}, '读进度'],
|
|
88
|
+
['coach_schedule', {}, '读日程'],
|
|
89
|
+
['coach_pool', { node: '区间 DP' }, '查题池(无库 → 降级)'],
|
|
90
|
+
['coach_assign', { cursor: '动态规划基础', node: '区间 DP', deliverable: '贴代码', minutes: 40 }, '布置(新知识点不给讲解段)'],
|
|
91
|
+
['coach_assign', { cursor: '动态规划基础', node: '区间 DP', deliverable: '贴代码', minutes: 40, teachMinutes: 15 }, '布置(带讲解段)'],
|
|
92
|
+
['coach_assign', { cursor: '动态规划基础', node: '不存在的节点', deliverable: 'x', minutes: 40 }, '布置到地图外'],
|
|
93
|
+
['coach_diagnose', { code: 'int main(){return 0;}', problem: '1015D', category: '不认真', node: '区间 DP', evidence: [], summary: '测试' }, '判因'],
|
|
94
|
+
['coach_mark', { node: '记忆化搜索', status: 'verified' }, '手标 verified(该被拒)'],
|
|
95
|
+
['coach_set_cursor', { node: '区间 DP' }, '移游标'],
|
|
96
|
+
['coach_ping', {}, '探针'],
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
// 归一化:把**有意不同**的东西抹平,免得假差异淹掉真差异。
|
|
100
|
+
// ① 包名(改名了)
|
|
101
|
+
// ② 数据目录路径(两份故意用不同临时目录,否则后跑的会看到先跑的那份被写过)
|
|
102
|
+
const ALLOWED = [
|
|
103
|
+
['name', /^(dsh-coach|acmer-coach)$/],
|
|
104
|
+
['version', /^1\.0\.0$/],
|
|
105
|
+
]
|
|
106
|
+
const strip = (s) => s
|
|
107
|
+
.replace(/acmer-coach/g, 'dsh-coach')
|
|
108
|
+
.split(join(base, 'A')).join('<DATA>')
|
|
109
|
+
.split(join(base, 'B')).join('<DATA>')
|
|
110
|
+
.split(base).join('<DATA>')
|
|
111
|
+
.replace(/\\\\/g, '/').replace(/\//g, '/').split('<DATA>').join('<DATA>')
|
|
112
|
+
const norm = (v) => JSON.parse(JSON.stringify(v ?? null, (k, x) =>
|
|
113
|
+
typeof x === 'string' ? strip(x) : x))
|
|
114
|
+
|
|
115
|
+
for (const [tool, args, label] of CALLS) {
|
|
116
|
+
const ta = A.byName.get(tool), tb = B.byName.get(tool)
|
|
117
|
+
if (!ta || !tb) { check(`${tool} · ${label}`, false, '有一边没这个工具'); continue }
|
|
118
|
+
let ra, rb, ea, eb
|
|
119
|
+
try { ra = norm(await ta.execute(args, undefined)) } catch (e) { ea = e.message }
|
|
120
|
+
try { rb = norm(await tb.execute(args, undefined)) } catch (e) { eb = e.message }
|
|
121
|
+
const sa = JSON.stringify(ra ?? { ERR: ea }), sb = JSON.stringify(rb ?? { ERR: eb })
|
|
122
|
+
if (sa === sb) { check(`${tool} · ${label}`, true) }
|
|
123
|
+
else {
|
|
124
|
+
// 探针那类返回值里带包名/路径,逐键比
|
|
125
|
+
const ka = Object.keys(ra ?? {}), kb = Object.keys(rb ?? {})
|
|
126
|
+
const dk = [...new Set([...ka, ...kb])].filter((k) => JSON.stringify(ra?.[k]) !== JSON.stringify(rb?.[k]))
|
|
127
|
+
const benign = dk.every((k) => ALLOWED.some(([n]) => k === n))
|
|
128
|
+
if (benign) { check(`${tool} · ${label}`, true, `仅包名/版本不同(${dk.join(',')})`) }
|
|
129
|
+
else { diff++; check(`${tool} · ${label}`, false, `差异字段:${dk.join(', ')}`) }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
console.log('\n── 3. 渲染文本:他看到的那些字 ──')
|
|
134
|
+
for (const [tool, args, label] of CALLS.filter((c) => c[0] !== 'coach_diagnose')) {
|
|
135
|
+
const ta = A.byName.get(tool), tb = B.byName.get(tool)
|
|
136
|
+
if (!ta?.output?.render || !tb?.output?.render) continue
|
|
137
|
+
let va, vb
|
|
138
|
+
try { va = await ta.execute(args, undefined) } catch { continue }
|
|
139
|
+
try { vb = await tb.execute(args, undefined) } catch { continue }
|
|
140
|
+
const ra = strip(ta.output.render(args, va)[0]?.text ?? '')
|
|
141
|
+
const rb = strip(tb.output.render(args, vb)[0]?.text ?? '')
|
|
142
|
+
const same = ra === rb
|
|
143
|
+
check(`渲染 · ${tool} · ${label}`, same, same ? '' : '文本不一致')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log('\n── 4. 规则覆盖度(线上 AGENTS.md vs 打包的规则文件)──')
|
|
147
|
+
{
|
|
148
|
+
const rules = readFileSync(join(import.meta.dirname, '..', 'assets', 'rules', 'coach-rules.md'), 'utf8')
|
|
149
|
+
// 线上规则里的九条硬规则,在开源版里都要能找到对应
|
|
150
|
+
const MARKS = [
|
|
151
|
+
['一次只给一个动作', /一次只给一个动作|禁止候选列表/],
|
|
152
|
+
['指令走 coach_assign', /coach_assign/],
|
|
153
|
+
['先讲再出题', /先讲,再出题|开一个他从没碰过的知识点/],
|
|
154
|
+
['判因要引代码原文', /引用.*代码.*原文|原文片段/],
|
|
155
|
+
['检测由教练主动提', /检测是你主动提的/],
|
|
156
|
+
['tier/entry/at 三样', /tier.*entry.*at|entry.*学不学得起/],
|
|
157
|
+
['排块走 schedule→plan', /排训练时间走/],
|
|
158
|
+
['反依赖(不给答案)', /禁止直接给完整解法/],
|
|
159
|
+
['元认知动作', /元认知/],
|
|
160
|
+
['时间铁律', /报时铁律/],
|
|
161
|
+
['安全红线', /Prompt 注入防御/],
|
|
162
|
+
['赛场读题', /区域赛难度分布随机/],
|
|
163
|
+
]
|
|
164
|
+
for (const [name, re] of MARKS) check(`规则含「${name}」`, re.test(rules))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
rmSync(base, { recursive: true, force: true })
|
|
168
|
+
console.log(`\n${fail === 0 ? '✓ 保真度通过' : '✗ 有差异'} — ${pass} 通过 / ${fail} 失败(其中 ${diff} 条是真差异)`)
|
|
169
|
+
process.exit(fail === 0 ? 0 : 1)
|