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
package/dev/run-all.sh
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# 一条命令跑全套验收。
|
|
3
|
+
#
|
|
4
|
+
# 六套:
|
|
5
|
+
# verify / verify-shape 主验收(509 条断言 + 19 个工具的形状扫描)
|
|
6
|
+
# w1-check 解绑、夹具闸门、规则注入
|
|
7
|
+
# wiki-check OI Wiki 检索
|
|
8
|
+
# setup-check 冷启动(离线)
|
|
9
|
+
# parity-check 和线上那份的保真度对比
|
|
10
|
+
#
|
|
11
|
+
# 主验收跑的是**已安装副本**(不是源码)—— 那才是运行时真正加载的东西。
|
|
12
|
+
# 所以这里先搭一个隔离的"已安装"目录,不碰任何真实 profile。
|
|
13
|
+
#
|
|
14
|
+
# 跑法:bash dev/run-all.sh
|
|
15
|
+
# 加 COACH_TEST_NET=1 连联网段一起跑
|
|
16
|
+
set -uo pipefail
|
|
17
|
+
cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 1
|
|
18
|
+
SRC="$(pwd)"
|
|
19
|
+
|
|
20
|
+
TMP="$(mktemp -d)"
|
|
21
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
22
|
+
INST="$TMP/profiles/web/node_modules/acmer-coach"
|
|
23
|
+
mkdir -p "$INST" "$TMP/knowledge"
|
|
24
|
+
tar cf - --exclude=node_modules --exclude=dev --exclude=.git . | (cd "$INST" && tar xf -)
|
|
25
|
+
|
|
26
|
+
# 隔离目录也得能解析依赖(yaml、@deepseek-ai/*)。
|
|
27
|
+
# 真实安装时这两样由 pnpm 装好;这里用 junction 接到 dsh 自带的那份。
|
|
28
|
+
# 用 node 建而不是 cmd:Windows 路径带空格,走 shell 的引号很容易被吃掉。
|
|
29
|
+
APP_NM="${DSH_APP_NODE_MODULES:-C:/Aplication/dsh desktop/resources/app/node_modules}"
|
|
30
|
+
if [ -d "$APP_NM" ]; then
|
|
31
|
+
node -e "
|
|
32
|
+
const fs=require('fs');
|
|
33
|
+
try { fs.symlinkSync(process.argv[1], process.argv[2], 'junction') } catch(e) { if(e.code!=='EEXIST') throw e }
|
|
34
|
+
" "$APP_NM" "$INST/node_modules" || echo "⚠ junction 建失败,主验收会缺依赖"
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
RC=0
|
|
38
|
+
run() { # run <名字> <命令...>
|
|
39
|
+
local name="$1"; shift
|
|
40
|
+
printf '%-16s ' "$name"
|
|
41
|
+
if out=$("$@" 2>&1); then
|
|
42
|
+
echo "✓ $(echo "$out" | tail -1)"
|
|
43
|
+
else
|
|
44
|
+
echo "✗"
|
|
45
|
+
echo "$out" | tail -25 | sed 's/^/ /'
|
|
46
|
+
RC=1
|
|
47
|
+
fi
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export DSH_HOME="$TMP"
|
|
51
|
+
export COACH_VERIFY_PROFILE=web
|
|
52
|
+
export COACH_DEFAULT_KNOWLEDGE="$TMP/knowledge"
|
|
53
|
+
|
|
54
|
+
cd "$TMP/profiles/web/node_modules/acmer-coach"
|
|
55
|
+
run "verify" node verify.mjs
|
|
56
|
+
run "verify-shape" node verify-shape.mjs
|
|
57
|
+
cd "$SRC"
|
|
58
|
+
run "w1-check" node dev/w1-check.mjs
|
|
59
|
+
run "wiki-check" node dev/wiki-check.mjs
|
|
60
|
+
run "setup-check" node dev/setup-check.mjs
|
|
61
|
+
run "parity-check" node dev/parity-check.mjs
|
|
62
|
+
|
|
63
|
+
echo
|
|
64
|
+
if [ "${COACH_TEST_NET:-0}" = "1" ]; then
|
|
65
|
+
echo "(联网段)"
|
|
66
|
+
COACH_TEST_NET=1 node dev/setup-check.mjs "${1:-tourist}" 2>&1 | tail -8 | sed 's/^/ /'
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
[ $RC -eq 0 ] && echo "✓ 全套通过" || echo "✗ 有失败"
|
|
70
|
+
exit $RC
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// W2 验收:冷启动(搭建 + 同步)。
|
|
2
|
+
//
|
|
3
|
+
// 分两段:
|
|
4
|
+
// · 离线段**每次都跑** —— status / init 的行为、幂等性、不覆盖已有文件
|
|
5
|
+
// · 联网段要显式开(COACH_TEST_NET=1)—— 真打 CF 公开接口,
|
|
6
|
+
// 走完整条链:拉账号 → 拉提交 → 拉题库 → 算接触证据
|
|
7
|
+
//
|
|
8
|
+
// 跑法:node dev/setup-check.mjs 只用临时目录验离线
|
|
9
|
+
// COACH_TEST_NET=1 node dev/setup-check.mjs [handle]
|
|
10
|
+
import { mkdtempSync, writeFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
let pass = 0, fail = 0
|
|
15
|
+
const check = (name, ok, detail = '') => {
|
|
16
|
+
console.log(`${ok ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
17
|
+
ok ? pass++ : fail++
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const home = mkdtempSync(join(tmpdir(), 'setup-'))
|
|
21
|
+
process.env.COACH_DATA_DIR = join(home, 'knowledge')
|
|
22
|
+
process.env.COACH_DB = join(home, 'data', 'training.db')
|
|
23
|
+
|
|
24
|
+
const load = async (tag) => {
|
|
25
|
+
const mod = await import(`../index.js?setup=${tag}`)
|
|
26
|
+
const reg = []
|
|
27
|
+
mod.apply({ tools: { register: (t) => reg.push(t) }, inject: () => {}, on: () => {}, get: () => undefined })
|
|
28
|
+
return reg.find((t) => t.name === 'coach_setup')
|
|
29
|
+
}
|
|
30
|
+
const tool = await load('a')
|
|
31
|
+
const call = (a) => tool.execute(a, undefined)
|
|
32
|
+
|
|
33
|
+
console.log('── 1. 空环境体检:要如实说「缺」,不能假装没事 ──')
|
|
34
|
+
{
|
|
35
|
+
const r = await call({ action: 'status' })
|
|
36
|
+
check('注册了 coach_setup', Boolean(tool))
|
|
37
|
+
check('ok=false(还没搭)', r.ok === false, `ok=${r.ok}`)
|
|
38
|
+
const st = Object.fromEntries(r.items.map((i) => [i.name, i.status]))
|
|
39
|
+
check('数据目录报「缺」', st['数据目录'] === '缺', st['数据目录'])
|
|
40
|
+
check('训练库报「缺」', st['训练库'] === '缺', st['训练库'])
|
|
41
|
+
check('给出了下一步', r.note.includes('init'), r.note.slice(0, 50))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log('\n── 2. init:建目录、铺地图、建库 ──')
|
|
45
|
+
{
|
|
46
|
+
const r = await call({ action: 'init' })
|
|
47
|
+
check('ok', r.ok === true, r.note)
|
|
48
|
+
check('数据目录真的建了', existsSync(process.env.COACH_DATA_DIR))
|
|
49
|
+
check('地图铺过去了', existsSync(join(process.env.COACH_DATA_DIR, 'MAP.yaml')))
|
|
50
|
+
check('训练库真的建了', existsSync(process.env.COACH_DB))
|
|
51
|
+
check('**没把 LICENSE 当数据拷进去**',
|
|
52
|
+
!existsSync(join(process.env.COACH_DATA_DIR, 'LICENSE')))
|
|
53
|
+
|
|
54
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
55
|
+
const db = new DatabaseSync(process.env.COACH_DB, { readOnly: true })
|
|
56
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map((r) => r.name)
|
|
57
|
+
for (const t of ['users', 'unified_submissions', 'unified_problems', 'rating_history', 'diff_rating_map']) {
|
|
58
|
+
check(`建了表 ${t}`, tables.includes(t))
|
|
59
|
+
}
|
|
60
|
+
check('难度映射灌进去了(13 行)',
|
|
61
|
+
db.prepare('SELECT COUNT(*) c FROM diff_rating_map').get().c === 13)
|
|
62
|
+
db.close()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log('\n── 3. init 幂等:不许覆盖用户已有的东西 ──')
|
|
66
|
+
{
|
|
67
|
+
const prog = join(process.env.COACH_DATA_DIR, 'PROGRESS.yaml')
|
|
68
|
+
writeFileSync(prog, 'version: 1\ncursor: 我自己写的\n')
|
|
69
|
+
const mapBefore = readFileSync(join(process.env.COACH_DATA_DIR, 'MAP.yaml'), 'utf8')
|
|
70
|
+
await call({ action: 'init' })
|
|
71
|
+
check('用户的 PROGRESS.yaml 没被动', readFileSync(prog, 'utf8').includes('我自己写的'))
|
|
72
|
+
check('已有的 MAP.yaml 没被覆盖', readFileSync(join(process.env.COACH_DATA_DIR, 'MAP.yaml'), 'utf8') === mapBefore)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log('\n── 4. 体检:库空 / 有数据两种状态要分得开 ──')
|
|
76
|
+
{
|
|
77
|
+
const r = await call({ action: 'status' })
|
|
78
|
+
check('地图那项是 ok', r.items.find((i) => i.name === '地图').status === 'ok')
|
|
79
|
+
check('训练库认得出来(空库)', r.items.find((i) => i.name === '训练库').detail.includes('0 条提交'),
|
|
80
|
+
r.items.find((i) => i.name === '训练库').detail)
|
|
81
|
+
check('提示下一步去 sync', r.note.includes('sync'), r.note.slice(0, 60))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log('\n── 5. sync 不给 handle → 要问,不要瞎猜一个 ──')
|
|
85
|
+
{
|
|
86
|
+
const r = await call({ action: 'sync' })
|
|
87
|
+
check('ok=false', r.ok === false)
|
|
88
|
+
check('明确说「问他」', r.note.includes('handle'), r.note.slice(0, 60))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log('\n── 6. 题池随包发(没有它 coach_pool 挑不出「他没做过」的题)──')
|
|
92
|
+
{
|
|
93
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
94
|
+
const db = new DatabaseSync(process.env.COACH_DB, { readOnly: true })
|
|
95
|
+
const lib = db.prepare('SELECT COUNT(*) c FROM unified_problems WHERE is_library=1').get().c
|
|
96
|
+
check('题库灌进去了', lib > 40000, `${lib} 道`)
|
|
97
|
+
const by = db.prepare(
|
|
98
|
+
"SELECT platform, COUNT(*) c FROM unified_problems WHERE is_library=1 GROUP BY platform").all()
|
|
99
|
+
for (const p of ['codeforces', 'luogu', 'nowcoder']) {
|
|
100
|
+
const n = by.find((r) => r.platform === p)?.c ?? 0
|
|
101
|
+
check(` 含 ${p}`, n > 5000, `${n} 道`)
|
|
102
|
+
}
|
|
103
|
+
check('题池带难度和标签(挑题要用)',
|
|
104
|
+
db.prepare("SELECT COUNT(*) c FROM unified_problems WHERE is_library=1 AND difficulty>0 AND tags!=''").get().c > 35000)
|
|
105
|
+
db.close()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log('\n── 7. 记录导入(洛谷/牛客走这条;CF 有公开接口不用)──')
|
|
109
|
+
{
|
|
110
|
+
// 先拿 import 工具(setup 之外还要它)
|
|
111
|
+
const mod2 = await import('../index.js?imp=1')
|
|
112
|
+
const reg2 = []
|
|
113
|
+
mod2.apply({ tools: { register: (t) => reg2.push(t) }, inject: () => {}, on: () => {}, get: () => undefined })
|
|
114
|
+
const imp = reg2.find((t) => t.name === 'coach_import')
|
|
115
|
+
check('注册了 coach_import', Boolean(imp))
|
|
116
|
+
const ic = (a) => imp.execute(a, undefined)
|
|
117
|
+
|
|
118
|
+
// 洛谷:status 是数字码,只认 12 → AC,其余 → Other(细分码没映射,见 lib/setup.js)
|
|
119
|
+
const luogu = [
|
|
120
|
+
{ platform: 'luogu', problem_id: 'P1001', verdict: '12', submitted_at: '2026-09-01T10:00:00+08:00' },
|
|
121
|
+
{ platform: 'luogu', problem_id: 'P1001', verdict: '6', submitted_at: '2026-09-01T09:00:00+08:00' },
|
|
122
|
+
{ platform: 'luogu', problem_id: 'P1002', verdict: '6', submitted_at: '2026-09-01T11:00:00+08:00' },
|
|
123
|
+
]
|
|
124
|
+
const r1 = await ic({ records: luogu })
|
|
125
|
+
check('导入成功', r1.ok === true, r1.note || `${r1.inserted} 条`)
|
|
126
|
+
check('同一题多次提交 → 只留一条 AC(不是把提交数当题数)', r1.inserted === 2,
|
|
127
|
+
`inserted=${r1.inserted}(3 条输入:P1001 的 AC 覆盖了它的失败提交,P1002 只有失败)`)
|
|
128
|
+
|
|
129
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
130
|
+
const db = new DatabaseSync(process.env.COACH_DB, { readOnly: true })
|
|
131
|
+
const vs = db.prepare("SELECT problem_id, verdict FROM unified_submissions WHERE platform='luogu' ORDER BY problem_id").all()
|
|
132
|
+
check('洛谷 12 → AC', vs.some((r) => r.problem_id === 'P1001' && r.verdict === 'AC'),
|
|
133
|
+
JSON.stringify(vs))
|
|
134
|
+
check('非 12 → Other(细分码没映射,宁可少说不可猜)',
|
|
135
|
+
vs.every((r) => ['AC', 'Other'].includes(r.verdict)), vs.map((r) => r.verdict).join(','))
|
|
136
|
+
db.close()
|
|
137
|
+
|
|
138
|
+
// 牛客:NC 前缀要剥(插件里 normId 就是这么认的)
|
|
139
|
+
const r2 = await ic({ records: [{ platform: 'nowcoder', problem_id: 'NC204457', verdict: 'AC' }] })
|
|
140
|
+
check('牛客 NC 前缀被剥掉', r2.inserted === 1, `inserted=${r2.inserted}`)
|
|
141
|
+
const db2 = new DatabaseSync(process.env.COACH_DB, { readOnly: true })
|
|
142
|
+
check('存的是裸数字',
|
|
143
|
+
Boolean(db2.prepare("SELECT 1 FROM unified_submissions WHERE platform='nowcoder' AND problem_id='204457'").get()))
|
|
144
|
+
db2.close()
|
|
145
|
+
|
|
146
|
+
// 再导一次同一个文件:**不该把 AC 覆盖成失败**
|
|
147
|
+
const r3 = await ic({ records: [{ platform: 'luogu', problem_id: 'P1001', verdict: '6' }] })
|
|
148
|
+
const db3 = new DatabaseSync(process.env.COACH_DB, { readOnly: true })
|
|
149
|
+
check('已 AC 的题再导入失败记录 → 不覆盖',
|
|
150
|
+
Boolean(db3.prepare("SELECT 1 FROM unified_submissions WHERE platform='luogu' AND problem_id='P1001' AND verdict='AC'").get()),
|
|
151
|
+
`这次 inserted=${r3.inserted}`)
|
|
152
|
+
db3.close()
|
|
153
|
+
|
|
154
|
+
// 认不出的记录要计数,不是静默丢
|
|
155
|
+
const r4 = await ic({ records: [{ platform: 'atcoder', problem_id: 'abc300_f', verdict: 'AC' }] })
|
|
156
|
+
check('不认识的平台被跳过并计数', r4.skipped === 1, `skipped=${r4.skipped}`)
|
|
157
|
+
|
|
158
|
+
// 文件形式(扩展导出就是这个)
|
|
159
|
+
const f = join(home, 'records.jsonl')
|
|
160
|
+
writeFileSync(f, luogu.map((r) => JSON.stringify(r)).join('\n'))
|
|
161
|
+
const r5 = await ic({ path: f })
|
|
162
|
+
check('能吃 JSONL 文件', r5.ok === true, `inserted=${r5.inserted}`)
|
|
163
|
+
const r6 = await ic({ path: join(home, '不存在.json') })
|
|
164
|
+
check('文件不存在 → 如实报错', r6.ok === false && r6.note.includes('读不了'), r6.note.slice(0, 50))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (process.env.COACH_TEST_NET === '1') {
|
|
168
|
+
const handle = process.argv[2] || 'tourist'
|
|
169
|
+
console.log(`\n── 6. 联网:真打 CF 接口拉「${handle}」 ──`)
|
|
170
|
+
const t0 = Date.now()
|
|
171
|
+
const r = await call({ action: 'sync', handle, problemset: false })
|
|
172
|
+
const secs = ((Date.now() - t0) / 1000).toFixed(1)
|
|
173
|
+
check('同步成功', r.ok === true, `${secs}s ${r.note.slice(0, 80)}`)
|
|
174
|
+
if (r.ok) {
|
|
175
|
+
check('拿到了 rating', r.rating > 0, String(r.rating))
|
|
176
|
+
check('拿到了提交记录', r.submissions > 0, `${r.submissions} 条`)
|
|
177
|
+
check('拿到了接触证据', r.evidence.length > 0, `${r.evidence.length} 个标签`)
|
|
178
|
+
console.log(' 证据前 5:', r.evidence.slice(0, 5)
|
|
179
|
+
.map((e) => `${e.tag}(AC ${e.ac})`).join(' '))
|
|
180
|
+
check('证据是**事实**不是结论(有 ac 和 tried 两个数)',
|
|
181
|
+
r.evidence.every((e) => typeof e.ac === 'number' && typeof e.tried === 'number'))
|
|
182
|
+
} else {
|
|
183
|
+
console.log(' ', r.note)
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
console.log('\n(联网段跳过:设 COACH_TEST_NET=1 打开)')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
rmSync(home, { recursive: true, force: true })
|
|
190
|
+
console.log(`\n${fail === 0 ? '✓ 全绿' : '✗ 有失败'} — ${pass} 通过 / ${fail} 失败`)
|
|
191
|
+
process.exit(fail === 0 ? 0 : 1)
|
package/dev/w1-check.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// W1 行为验收:四个硬编码常量解绑后,还认不认路。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独一个文件而不是塞进 verify.mjs:
|
|
4
|
+
// verify.mjs 跑的是**已安装副本**(profiles/web/node_modules/acmer-coach),
|
|
5
|
+
// 而这一关要在**开发目录**里验,且要反复换 COACH_DATA_DIR 重载模块
|
|
6
|
+
// —— 两者用的解析根不同,混在一起每跑一次都得先装一遍。
|
|
7
|
+
//
|
|
8
|
+
// 跑法:node dev/w1-check.mjs
|
|
9
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { tmpdir, homedir } from 'node:os'
|
|
11
|
+
import { join, basename } from 'node:path'
|
|
12
|
+
|
|
13
|
+
const HERE = new URL('..', import.meta.url).pathname.replace(/^\//, '')
|
|
14
|
+
let pass = 0, fail = 0
|
|
15
|
+
const check = (name, ok, detail = '') => {
|
|
16
|
+
console.log(`${ok ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
17
|
+
ok ? pass++ : fail++
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── 造一份最小数据目录 ────────────────────────────────────────────────
|
|
21
|
+
function makeLedger() {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), 'w1-'))
|
|
23
|
+
writeFileSync(join(dir, 'MAP.yaml'), `meta:
|
|
24
|
+
version: 3
|
|
25
|
+
node_count: 2
|
|
26
|
+
nodes:
|
|
27
|
+
- id: A
|
|
28
|
+
name: A
|
|
29
|
+
domain: 测试
|
|
30
|
+
depends: []
|
|
31
|
+
tier: normal
|
|
32
|
+
- id: B
|
|
33
|
+
name: B
|
|
34
|
+
domain: 测试
|
|
35
|
+
depends: [A]
|
|
36
|
+
tier: normal
|
|
37
|
+
`)
|
|
38
|
+
writeFileSync(join(dir, 'DEPENDS.yaml'), `meta:
|
|
39
|
+
version: 1
|
|
40
|
+
requires:
|
|
41
|
+
A: []
|
|
42
|
+
B: [A]
|
|
43
|
+
`)
|
|
44
|
+
writeFileSync(join(dir, 'skilltree.html'), '<html><body>stub</body></html>')
|
|
45
|
+
return dir
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const mockCtx = (registered, sections = []) => ({
|
|
49
|
+
tools: { register: (t) => registered.push(t) },
|
|
50
|
+
inject: () => {},
|
|
51
|
+
on: () => {},
|
|
52
|
+
// systemPrompt 服务:插件通过它把教练规则注进 system prompt
|
|
53
|
+
get: (name) => (name === 'systemPrompt'
|
|
54
|
+
? { section: (s) => sections.push(s) }
|
|
55
|
+
: undefined),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/** 用指定的 env 重新加载一份模块实例(ESM 按 URL 缓存,靠 query 破缓存)。 */
|
|
59
|
+
async function loadWith(env, tag) {
|
|
60
|
+
const saved = {}
|
|
61
|
+
for (const [k, v] of Object.entries(env)) {
|
|
62
|
+
saved[k] = process.env[k]
|
|
63
|
+
if (v === undefined) delete process.env[k]
|
|
64
|
+
else process.env[k] = v
|
|
65
|
+
}
|
|
66
|
+
const mod = await import(`../index.js?w1=${tag}`)
|
|
67
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
68
|
+
if (v === undefined) delete process.env[k]
|
|
69
|
+
else process.env[k] = v
|
|
70
|
+
}
|
|
71
|
+
const registered = [], sections = []
|
|
72
|
+
mod.apply(mockCtx(registered, sections))
|
|
73
|
+
return { mod, byName: new Map(registered.map((t) => [t.name, t])), sections }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ══════════════════════════════════════════════════════════════════
|
|
77
|
+
console.log('── 1. 默认路径从家目录派生(不再写死) ──')
|
|
78
|
+
{
|
|
79
|
+
const { byName } = await loadWith({ COACH_DATA_DIR: undefined, COACH_TRAINER: undefined }, 'default')
|
|
80
|
+
const ping = byName.get('coach_ping')
|
|
81
|
+
check('coach_ping 注册了', Boolean(ping))
|
|
82
|
+
const v = await ping.execute({}, undefined)
|
|
83
|
+
// 默认目录 = ~/.dsh/knowledge。本机能跑说明家目录派生没问题。
|
|
84
|
+
check('默认数据目录可达(~/.dsh/knowledge)', typeof v?.dataDir === 'string' && v.dataDir.includes('.dsh'),
|
|
85
|
+
`dataDir=${v?.dataDir}`)
|
|
86
|
+
// 断言的是**推导逻辑**,不是"不含某几个字面量" ——
|
|
87
|
+
// 后者会把名字写进测试文件本身,等于把要清的东西留在了仓库里。
|
|
88
|
+
const want = join(homedir(), '.dsh', 'knowledge')
|
|
89
|
+
check('默认路径 = 家目录派生(不是写死的)',
|
|
90
|
+
String(v?.dataDir).replace(/\\/g, '/') === want.replace(/\\/g, '/'), `dataDir=${v?.dataDir}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log('── 2. COACH_DATA_DIR 覆盖生效 ──')
|
|
94
|
+
const ledger = makeLedger()
|
|
95
|
+
{
|
|
96
|
+
const { byName } = await loadWith({ COACH_DATA_DIR: ledger, COACH_TRAINER: undefined }, 'tmp')
|
|
97
|
+
const v = await byName.get('coach_ping').execute({}, undefined)
|
|
98
|
+
check('指向了临时目录', String(v?.dataDir).replace(/\\/g, '/') === ledger.replace(/\\/g, '/'),
|
|
99
|
+
`dataDir=${v?.dataDir}`)
|
|
100
|
+
check('三个数据文件都探到', v?.ok === true, v?.files?.filter((f) => f.status !== 'ok').map((f) => f.file).join(','))
|
|
101
|
+
check('探针仍是 3 个', v?.files?.length === 3)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log('── 3. 夹具闸门:没有显式临时目录 → 拦住 ──')
|
|
105
|
+
{
|
|
106
|
+
const { mod } = await loadWith({ COACH_DATA_DIR: undefined }, 'gate-off')
|
|
107
|
+
let err = null
|
|
108
|
+
try {
|
|
109
|
+
await mod.schedApi.planDay('2026-01-01', [{ from: '09:00', kind: 'review', node: 'A' }])
|
|
110
|
+
} catch (e) { err = e }
|
|
111
|
+
check('抛错', err instanceof Error, err?.message?.slice(0, 60))
|
|
112
|
+
check('理由含「夹具」', Boolean(err?.message?.includes('夹具')))
|
|
113
|
+
check('理由含「真账本」', Boolean(err?.message?.includes('真账本')))
|
|
114
|
+
check('理由点明怎么绕过(COACH_DATA_DIR)', Boolean(err?.message?.includes('COACH_DATA_DIR')))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log('── 4. 夹具闸门:显式指定了 → 放行 ──')
|
|
118
|
+
{
|
|
119
|
+
const { mod } = await loadWith({ COACH_DATA_DIR: ledger }, 'gate-on')
|
|
120
|
+
let err = null
|
|
121
|
+
try {
|
|
122
|
+
await mod.schedApi.planDay('2026-01-01', [{ from: '09:00', kind: 'review', node: 'A' }])
|
|
123
|
+
} catch (e) { err = e }
|
|
124
|
+
check('闸门不再拦(其它错可以)', !err?.message?.includes('夹具写接口'), err?.message?.slice(0, 80) ?? '(无错)')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log('── 5. TRAINER 不再写死:没给 handle 就不该出现人名 ──')
|
|
128
|
+
{
|
|
129
|
+
const { byName } = await loadWith({ COACH_DATA_DIR: ledger, COACH_TRAINER: undefined }, 'trainer')
|
|
130
|
+
// coach_next 会读地图;DB 打不开时的报错文案里不该出现硬编码 handle
|
|
131
|
+
// 看**源码形状**而不是返回值:没给 COACH_TRAINER 时 TRAINER 必须退成 null,
|
|
132
|
+
// 后续由 trainerId() 取库里第一条用户。写死一个 handle 就会在这里露馅。
|
|
133
|
+
const src = readFileSync(new URL('../index.js', import.meta.url), 'utf8')
|
|
134
|
+
check('TRAINER 没有写死的默认值',
|
|
135
|
+
/const TRAINER = process\.env\.COACH_TRAINER \|\| null/.test(src))
|
|
136
|
+
check('trainerId 有"取第一条用户"的兜底',
|
|
137
|
+
/SELECT id FROM users ORDER BY id LIMIT 1/.test(src))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
rmSync(ledger, { recursive: true, force: true })
|
|
141
|
+
|
|
142
|
+
console.log('── 6. 规则进 system prompt(不是消息流)──')
|
|
143
|
+
{
|
|
144
|
+
const { sections } = await loadWith({ COACH_DATA_DIR: undefined }, 'rules')
|
|
145
|
+
const s = sections.find((x) => x.name === 'coach:rules')
|
|
146
|
+
check('注册了 coach:rules 段', Boolean(s))
|
|
147
|
+
check('order 是有限的数', Number.isFinite(s?.order), String(s?.order))
|
|
148
|
+
check('排在人设之后、通用策略之前(0 < order < 500)',
|
|
149
|
+
s?.order > 0 && s?.order < 500, String(s?.order))
|
|
150
|
+
check('规则正文非空且含九条硬规则', (s?.text?.length ?? 0) > 2000 && s.text.includes('coach_next'),
|
|
151
|
+
`${s?.text?.length ?? 0} 字符`)
|
|
152
|
+
// 同样断言**结构性性质**:规则里不该出现绝对路径,也不该出现本机用户名
|
|
153
|
+
// (用户名从 homedir() 现取 —— 源码里不写任何一个具体的名字)。
|
|
154
|
+
const uname = basename(homedir())
|
|
155
|
+
check('规则里没有绝对路径', !/[A-Za-z]:\\|\/Users\/|\/home\//.test(s?.text ?? ''))
|
|
156
|
+
check('规则里没有本机用户名', !(s?.text ?? '').includes(uname))
|
|
157
|
+
}
|
|
158
|
+
{
|
|
159
|
+
// 没有 systemPrompt 服务时要能静默跳过,别把插件加载拦死(headless)
|
|
160
|
+
const registered = []
|
|
161
|
+
const mod2 = await import('../index.js?w1=nosp')
|
|
162
|
+
let threw = null
|
|
163
|
+
try { mod2.apply({ tools: { register: (t) => registered.push(t) }, inject: () => {}, on: () => {} , get: () => undefined }) }
|
|
164
|
+
catch (e) { threw = e }
|
|
165
|
+
check('没有 systemPrompt 服务 → 不抛,工具照常注册', !threw && registered.length > 0,
|
|
166
|
+
threw ? threw.message : `${registered.length} 个工具`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
console.log(`\n${fail === 0 ? '✓ 全绿' : '✗ 有失败'} — ${pass} 通过 / ${fail} 失败`)
|
|
170
|
+
process.exit(fail === 0 ? 0 : 1)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// W5 验收:OI Wiki 本地检索。
|
|
2
|
+
//
|
|
3
|
+
// 用**教练真实会发的查询**验,不是编几个能过的:
|
|
4
|
+
// · 节点名(地图上的) → 应该走精确表,第一条就得对
|
|
5
|
+
// · 分类节点(网络流这种) → 应该落到子树里的页面
|
|
6
|
+
// · 自由文本 / 口语化提问 → 应该靠正文匹配兜住
|
|
7
|
+
// · 压根不存在的 → 应该**如实说找不到**,不是硬凑一个
|
|
8
|
+
//
|
|
9
|
+
// 跑法:node dev/wiki-check.mjs
|
|
10
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
|
|
14
|
+
let pass = 0, fail = 0
|
|
15
|
+
const check = (name, ok, detail = '') => {
|
|
16
|
+
console.log(`${ok ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
17
|
+
ok ? pass++ : fail++
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 这个工具不碰数据目录,但插件加载时要有个能读的地图(其它工具用)
|
|
21
|
+
const tmp = mkdtempSync(join(tmpdir(), 'wiki-'))
|
|
22
|
+
writeFileSync(join(tmp, 'MAP.yaml'), 'meta: {version: 3}\nnodes:\n - id: A\n name: A\n domain: 测试\n depends: []\n tier: normal\n')
|
|
23
|
+
writeFileSync(join(tmp, 'DEPENDS.yaml'), 'meta: {version: 1}\nrequires:\n A: []\n')
|
|
24
|
+
writeFileSync(join(tmp, 'skilltree.html'), '<html></html>')
|
|
25
|
+
process.env.COACH_DATA_DIR = tmp
|
|
26
|
+
|
|
27
|
+
const mod = await import('../index.js?wiki=1')
|
|
28
|
+
const registered = []
|
|
29
|
+
mod.apply({ tools: { register: (t) => registered.push(t) }, inject: () => {}, on: () => {} })
|
|
30
|
+
const wiki = registered.find((t) => t.name === 'coach_wiki')
|
|
31
|
+
check('注册了 coach_wiki', Boolean(wiki))
|
|
32
|
+
|
|
33
|
+
const call = (a) => wiki.execute(a, undefined)
|
|
34
|
+
|
|
35
|
+
// ── 1. 节点精确表 ─────────────────────────────────────────────────
|
|
36
|
+
console.log('\n── 1. 节点 → 页面(精确表)──')
|
|
37
|
+
for (const [node, want] of [
|
|
38
|
+
['线段树', 'ds/seg.md'],
|
|
39
|
+
['区间 DP', 'dp/interval.md'],
|
|
40
|
+
['插头 DP', 'dp/plug.md'],
|
|
41
|
+
['复杂度', 'basic/complexity.md'],
|
|
42
|
+
]) {
|
|
43
|
+
const r = await call({ action: 'node', node })
|
|
44
|
+
const top = r.pages[0]?.src
|
|
45
|
+
check(`${node} → ${want}`, top === want, top ?? r.note)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log('\n── 2. 分类节点(自己没有页面,落在子树里)──')
|
|
49
|
+
{
|
|
50
|
+
const r = await call({ action: 'node', node: '网络流' })
|
|
51
|
+
const srcs = r.pages.map((p) => p.src)
|
|
52
|
+
check('网络流 命中多个页面', srcs.length >= 3, `${srcs.length} 个`)
|
|
53
|
+
check('含最大流', srcs.some((s) => s.includes('max-flow')), srcs.slice(0, 3).join(' '))
|
|
54
|
+
}
|
|
55
|
+
{
|
|
56
|
+
const r = await call({ action: 'node', node: '莫队算法' })
|
|
57
|
+
check('莫队算法 落在子树页面(第四层也能收到)',
|
|
58
|
+
r.pages.some((p) => p.src.includes('mo-algo')), r.pages[0]?.src ?? r.note)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log('\n── 3. 自由文本 / 口语化 ──')
|
|
62
|
+
for (const [q, wantSub] of [
|
|
63
|
+
['Dijkstra', 'graph/'],
|
|
64
|
+
['四边形不等式', 'dp/'],
|
|
65
|
+
['字符串', 'string/'],
|
|
66
|
+
]) {
|
|
67
|
+
const r = await call({ action: 'search', query: q, topK: 5 })
|
|
68
|
+
const ok = r.pages.some((p) => p.src.includes(wantSub))
|
|
69
|
+
check(`搜「${q}」命中 ${wantSub}`, ok, r.pages.map((p) => p.src).slice(0, 3).join(' ') || r.note)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log('\n── 4. 读整页 ──')
|
|
73
|
+
{
|
|
74
|
+
const r = await call({ action: 'page', src: 'ds/seg.md' })
|
|
75
|
+
check('读到 ds/seg.md', r.ok && r.text.length > 500, `${r.text.length} 字符`)
|
|
76
|
+
check('正文是中文', /[一-龥]/.test(r.text))
|
|
77
|
+
const bad = await call({ action: 'page', src: '不存在/的页面.md' })
|
|
78
|
+
check('不存在的路径 → 如实报错,不是空字符串', !bad.ok && bad.note.length > 0, bad.note)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log('\n── 5. 查不到时必须说实话(不许硬凑)──')
|
|
82
|
+
{
|
|
83
|
+
const r = await call({ action: 'search', query: 'zzz不存在的知识点qqq', topK: 5 })
|
|
84
|
+
check('无关查询 → 0 结果', r.pages.length === 0, `${r.pages.length} 个`)
|
|
85
|
+
check('并且给出「照实说」的提示', r.note.includes('不确定'), r.note)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log('\n── 6. 输出形状合规(dsh 严格校验)──')
|
|
89
|
+
{
|
|
90
|
+
const r = await call({ action: 'search', query: '线段树', topK: 3 })
|
|
91
|
+
const shape = ['ok', 'note', 'pages', 'text', 'truncated', 'available']
|
|
92
|
+
check('顶层键齐全', shape.every((k) => k in r), Object.keys(r).join(','))
|
|
93
|
+
check('pages 每项四字段', r.pages.every((p) =>
|
|
94
|
+
typeof p.src === 'string' && typeof p.title === 'string' &&
|
|
95
|
+
typeof p.via === 'string' && typeof p.excerpt === 'string'))
|
|
96
|
+
check('render 不抛', Array.isArray(wiki.output.render({}, r)))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log(`\n${fail === 0 ? '✓ 全绿' : '✗ 有失败'} — ${pass} 通过 / ${fail} 失败`)
|
|
100
|
+
process.exit(fail === 0 ? 0 : 1)
|