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/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "acmer-coach",
|
|
3
|
+
"description": "小鲸教练插件:把「位置 + 判断」从小鲸的转述里拿回来。读 MAP/DEPENDS/PROGRESS,给唯一动作、判因、检测。热插拔,删掉即还原。",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=24.0.0"
|
|
8
|
+
},
|
|
9
|
+
"main": "index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.js",
|
|
12
|
+
"./client": "./lib/client.js",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"yaml": "^2.9.0"
|
|
17
|
+
},
|
|
18
|
+
"dsh": {
|
|
19
|
+
"engines": {
|
|
20
|
+
"dsh": ">=0.1.1-rc.1"
|
|
21
|
+
},
|
|
22
|
+
"bundle": {
|
|
23
|
+
"patch": "./cordis.patch.yml"
|
|
24
|
+
},
|
|
25
|
+
"client": {
|
|
26
|
+
"inject": [
|
|
27
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
28
|
+
],
|
|
29
|
+
"platform": "web"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT"
|
|
33
|
+
}
|
package/progress-cli.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// 进度读写 CLI —— 给非 JS 的调用方用(现在只剩技能树的离线预览 serve_map.py)。
|
|
2
|
+
//
|
|
3
|
+
// ── 为什么要有这个中间层 ─────────────────────────────────────────
|
|
4
|
+
// PROGRESS.yaml **只能有一个写入实现**。
|
|
5
|
+
// 让 Python 那边用 PyYAML 自己读写,立刻就是两个写者、两套 YAML 实现、
|
|
6
|
+
// 两种输出风格;而且 PyYAML 不吃注释,文件头那段状态说明第一次写就没了。
|
|
7
|
+
//
|
|
8
|
+
// 所以本文件**不自己实现任何规则** —— 它只把 argv 翻译成 progressApi 调用。
|
|
9
|
+
// 校验(节点是否存在、status 合不合法)和落盘(原子写、文件头重贴)
|
|
10
|
+
// 全在 index.js 里,和教练工具、和 /coach 页面路由共用同一份代码。
|
|
11
|
+
//
|
|
12
|
+
// ── 必须从「已安装副本」运行 ─────────────────────────────────────
|
|
13
|
+
// node <profiles/web>/node_modules/acmer-coach/progress-cli.mjs get
|
|
14
|
+
// 因为 index.js 要 import `yaml` 和 `@deepseek-ai/dsh-tools`,
|
|
15
|
+
// 只有装在 node_modules 下才解析得到(源码目录里跑会 ERR_MODULE_NOT_FOUND)。
|
|
16
|
+
//
|
|
17
|
+
// ── 契约 ─────────────────────────────────────────────────────────
|
|
18
|
+
// 入参:argv[2] = get | mark | cursor
|
|
19
|
+
// 出参:stdout 一行 JSON。**永远以 JSON 说话**,错误也走 JSON,
|
|
20
|
+
// 这样调用方不用去解析 stderr 的人话。
|
|
21
|
+
// 退出码:0 = 成功;1 = 被拒或出错(调用方看 ok 字段)
|
|
22
|
+
import { progressApi } from './index.js'
|
|
23
|
+
|
|
24
|
+
const out = (o) => { process.stdout.write(JSON.stringify(o) + '\n') }
|
|
25
|
+
const die = (err) => { out({ ok: false, reject: String(err) }); process.exit(1) }
|
|
26
|
+
|
|
27
|
+
const [, , cmd, a, b, c] = process.argv
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
if (cmd === 'get') {
|
|
31
|
+
const p = progressApi.load()
|
|
32
|
+
out({ ok: true, cursor: p.cursor, updated: p.updated, nodes: p.nodes })
|
|
33
|
+
} else if (cmd === 'mark') {
|
|
34
|
+
// a = 节点 id, b = studying | learned | none(none = 退回未学)
|
|
35
|
+
// ⚠️ verified 会被 progressApi.setStatus 拒掉 —— 自评不能冒充检测结论。
|
|
36
|
+
const r = progressApi.setStatus(a, b)
|
|
37
|
+
if (!r.ok) die(r.reject)
|
|
38
|
+
out({
|
|
39
|
+
ok: true, node: r.node, status: r.to, cursor: r.cursor,
|
|
40
|
+
verified: r.verifiedCount, learned: r.learnedCount, studying: r.studyingCount,
|
|
41
|
+
})
|
|
42
|
+
} else if (cmd === 'cursor') {
|
|
43
|
+
const r = progressApi.moveCursor(a)
|
|
44
|
+
if (!r.ok) die(r.reject)
|
|
45
|
+
out({ ok: true, cursor: r.cursor })
|
|
46
|
+
} else if (cmd === 'sched-get') {
|
|
47
|
+
const s = progressApi.loadSchedule()
|
|
48
|
+
out({ ok: true, updated: s.updated, days: s.days })
|
|
49
|
+
} else if (cmd === 'sched-set') {
|
|
50
|
+
// a = date, b = field, c = JSON 数组
|
|
51
|
+
let value
|
|
52
|
+
try {
|
|
53
|
+
value = JSON.parse(c ?? '[]')
|
|
54
|
+
} catch {
|
|
55
|
+
die(`值要是 JSON 数组,收到「${c ?? ''}」`)
|
|
56
|
+
}
|
|
57
|
+
const r = progressApi.setSchedule(a, b, value)
|
|
58
|
+
if (!r.ok) die(r.reject)
|
|
59
|
+
out({ ok: true, date: r.date, field: r.field, count: r.count, updated: r.updated })
|
|
60
|
+
} else {
|
|
61
|
+
die(`未知命令「${cmd}」,只有 get / mark / cursor`)
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
// 解析失败、磁盘写失败之类一律走这里。
|
|
65
|
+
// **绝不吞掉** —— 上层要靠这个区分「真的没进度」和「文件坏了」。
|
|
66
|
+
die(err?.message ?? err)
|
|
67
|
+
}
|
package/verify-shape.mjs
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// 全工具形状扫描:把 acmer-coach 注册的每个工具都真跑一次,
|
|
2
|
+
// 返回值**递归**对 output.schema 校验(type / required / additionalProperties)。
|
|
3
|
+
//
|
|
4
|
+
// 为什么需要这把尺子:dsh 对工具返回值是**严格校验**的 —— schema 里写了
|
|
5
|
+
// additionalProperties:false,返回值多一个键就是校验错,而报错会把工具真正
|
|
6
|
+
// 想说的话("你排错了" / "这个知识点不在图上")盖成"工具坏了"。
|
|
7
|
+
//
|
|
8
|
+
// 和 verify.mjs 的分工:verify.mjs 的 shapeDiff 也递归,但断言只挂在
|
|
9
|
+
// **写过的那些地方**;这把尺子扫的是**全部工具 / 全部调用路径**,
|
|
10
|
+
// 每条都带拒绝分支(被拒那条最容易形状不齐),还会跑一遍
|
|
11
|
+
// coach_pool → coach_test → coach_grade 的成功链路。
|
|
12
|
+
//
|
|
13
|
+
// 它抓的就是 coach_next 兜底分支那种错:`frontier[].at` 返回里有、schema
|
|
14
|
+
// 里没有 → 214 个末端节点全走那条路,工具 100% 报错。
|
|
15
|
+
//
|
|
16
|
+
// 全程在临时目录里跑(COACH_DATA_DIR / COACH_DB 指过去),真数据一动不动。
|
|
17
|
+
// 退出码非 0 = 有错配。
|
|
18
|
+
|
|
19
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import os from 'node:os'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import { pathToFileURL } from 'node:url'
|
|
23
|
+
|
|
24
|
+
// 跑的是 profile 里那份**已安装副本**(不是本目录的源码)——
|
|
25
|
+
// 副本才是运行时真正加载的东西。路径派生,要换 profile 就设 COACH_VERIFY_PROFILE。
|
|
26
|
+
const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
27
|
+
const PROFILE = process.env.COACH_VERIFY_PROFILE || 'web'
|
|
28
|
+
const INSTALLED = pathToFileURL(
|
|
29
|
+
path.join(DSH_HOME, 'profiles', PROFILE, 'node_modules', 'acmer-coach', 'index.js')).href
|
|
30
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'coach-sweep-'))
|
|
31
|
+
|
|
32
|
+
// ── 夹具:小地图 + 进度 + 空日程 ────────────────────────────────
|
|
33
|
+
writeFileSync(path.join(tmp, 'MAP.yaml'),
|
|
34
|
+
`meta: { version: 9, node_count: 6 }
|
|
35
|
+
nodes:
|
|
36
|
+
- id: A
|
|
37
|
+
name: A
|
|
38
|
+
domain: 测试
|
|
39
|
+
depends: []
|
|
40
|
+
- id: B
|
|
41
|
+
name: B
|
|
42
|
+
domain: 测试
|
|
43
|
+
depends: [A]
|
|
44
|
+
tier: core
|
|
45
|
+
entry: 1500
|
|
46
|
+
- id: C
|
|
47
|
+
name: C
|
|
48
|
+
domain: 测试
|
|
49
|
+
depends: [A]
|
|
50
|
+
tier: normal
|
|
51
|
+
entry: 1600
|
|
52
|
+
- id: 叶子
|
|
53
|
+
name: 叶子
|
|
54
|
+
domain: 测试
|
|
55
|
+
depends: [A]
|
|
56
|
+
tier: core
|
|
57
|
+
entry: 1700
|
|
58
|
+
- id: 够不着
|
|
59
|
+
name: 够不着
|
|
60
|
+
domain: 测试
|
|
61
|
+
depends: [A]
|
|
62
|
+
tier: core
|
|
63
|
+
entry: 2900
|
|
64
|
+
- id: 缺前置
|
|
65
|
+
name: 缺前置
|
|
66
|
+
domain: 测试
|
|
67
|
+
depends: [C]
|
|
68
|
+
tier: rare
|
|
69
|
+
entry: 1500
|
|
70
|
+
`)
|
|
71
|
+
writeFileSync(path.join(tmp, 'PROGRESS.yaml'),
|
|
72
|
+
`version: 1
|
|
73
|
+
cursor: A
|
|
74
|
+
updated: 2026-09-14
|
|
75
|
+
nodes:
|
|
76
|
+
A:
|
|
77
|
+
status: learned
|
|
78
|
+
叶子:
|
|
79
|
+
status: studying
|
|
80
|
+
at: 2026-09-01
|
|
81
|
+
`)
|
|
82
|
+
writeFileSync(path.join(tmp, 'SCHEDULE.yaml'), 'version: 1\ndays: {}\n')
|
|
83
|
+
writeFileSync(path.join(tmp, 'DEPENDS.yaml'), 'version: 1\n')
|
|
84
|
+
writeFileSync(path.join(tmp, 'skilltree.html'), '<html></html>')
|
|
85
|
+
// (探针不再 stat 项目文档,所以这里也不放那份夹具了,见 index.js 的 PROBE_FILES)
|
|
86
|
+
|
|
87
|
+
process.env.COACH_DATA_DIR = tmp
|
|
88
|
+
process.env.COACH_DB = path.join(tmp, 'training.db')
|
|
89
|
+
|
|
90
|
+
// ── 夹具库:让 coach_pool / coach_test / coach_grade 走得通 ──
|
|
91
|
+
{
|
|
92
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
93
|
+
const db = new DatabaseSync(process.env.COACH_DB)
|
|
94
|
+
db.exec(`
|
|
95
|
+
CREATE TABLE users (id INTEGER PRIMARY KEY, handle TEXT, display_name TEXT, cf_handle TEXT);
|
|
96
|
+
CREATE TABLE unified_submissions (id INTEGER PRIMARY KEY, user_id INTEGER, platform TEXT,
|
|
97
|
+
problem_id TEXT, verdict TEXT, time_ms INTEGER, language TEXT, submitted_at TEXT);
|
|
98
|
+
CREATE TABLE unified_problems (id INTEGER PRIMARY KEY, platform TEXT, problem_id TEXT,
|
|
99
|
+
title TEXT, difficulty REAL, tags TEXT, url TEXT);
|
|
100
|
+
CREATE TABLE diff_rating_map (platform TEXT, diff INTEGER, cf_rating INTEGER,
|
|
101
|
+
PRIMARY KEY (platform, diff));
|
|
102
|
+
CREATE TABLE rating_history (id INTEGER PRIMARY KEY, user_id INTEGER, rating INTEGER,
|
|
103
|
+
rank TEXT, max_rating INTEGER, recorded_at TEXT, platform TEXT);
|
|
104
|
+
INSERT INTO users VALUES (1, 'trainer', 'trainer', 'trainer');
|
|
105
|
+
INSERT INTO rating_history VALUES (1, 1, 1800, '900', 1800, '2026-09-09T00:35:00+08:00', 'codeforces');
|
|
106
|
+
INSERT INTO unified_problems VALUES (1,'luogu','P1','易题',3,'A','u');
|
|
107
|
+
INSERT INTO unified_problems VALUES (2,'luogu','P2','中题',5,'A','u');
|
|
108
|
+
INSERT INTO unified_problems VALUES (3,'luogu','P3','难题',8,'A','u');
|
|
109
|
+
INSERT INTO unified_problems VALUES (4,'luogu','P4','B题',5,'B','u');
|
|
110
|
+
INSERT INTO diff_rating_map VALUES ('luogu', 3, 1500);
|
|
111
|
+
INSERT INTO diff_rating_map VALUES ('luogu', 5, 1800);
|
|
112
|
+
INSERT INTO diff_rating_map VALUES ('luogu', 8, 2400);
|
|
113
|
+
INSERT INTO unified_submissions VALUES (1,1,'codeforces','1015D','WA',15,'C++23','2026-09-11T20:09:45+08:00');
|
|
114
|
+
INSERT INTO unified_submissions VALUES (2,1,'codeforces','1015D','AC',46,'C++23','2026-09-11T20:10:54+08:00');
|
|
115
|
+
`)
|
|
116
|
+
db.close()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const mockCtx = (registered) => ({
|
|
120
|
+
tools: { register: (t) => registered.push(t) },
|
|
121
|
+
inject: () => {},
|
|
122
|
+
// 钩子(agent/pre-step)。这里只收不调 —— 钩子的行为在第 32 节验,
|
|
123
|
+
// 这边只要保证"apply() 在有 on 的环境里不炸"。
|
|
124
|
+
on: () => {},
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// ── 递归校验 ───────────────────────────────────────────────────
|
|
128
|
+
function validate(schema, value, at, errs) {
|
|
129
|
+
const t = schema?.type
|
|
130
|
+
if (t === 'object') {
|
|
131
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
132
|
+
errs.push(`${at}: 期望 object,实到 ${Array.isArray(value) ? 'array' : typeof value}`); return
|
|
133
|
+
}
|
|
134
|
+
const props = schema.properties ?? {}
|
|
135
|
+
for (const k of Object.keys(value)) {
|
|
136
|
+
if (!(k in props)) errs.push(`${at}.${k}: schema 未声明(additionalProperties:false → dsh 校验会拒)`)
|
|
137
|
+
}
|
|
138
|
+
for (const [k, s] of Object.entries(props)) {
|
|
139
|
+
if (!(k in value)) { if (s.required) errs.push(`${at}.${k}: 缺 required 字段`); continue }
|
|
140
|
+
validate(s, value[k], `${at}.${k}`, errs)
|
|
141
|
+
}
|
|
142
|
+
} else if (t === 'array') {
|
|
143
|
+
if (!Array.isArray(value)) { errs.push(`${at}: 期望 array,实到 ${typeof value}`); return }
|
|
144
|
+
if (schema.items) value.forEach((v, i) => validate(schema.items, v, `${at}[${i}]`, errs))
|
|
145
|
+
} else if (t === 'integer') {
|
|
146
|
+
if (!Number.isInteger(value)) errs.push(`${at}: 期望 integer,实到 ${JSON.stringify(value)}`)
|
|
147
|
+
} else if (t === 'number') {
|
|
148
|
+
if (typeof value !== 'number') errs.push(`${at}: 期望 number,实到 ${typeof value}`)
|
|
149
|
+
} else if (t === 'boolean') {
|
|
150
|
+
if (typeof value !== 'boolean') errs.push(`${at}: 期望 boolean,实到 ${typeof value}`)
|
|
151
|
+
} else if (t === 'string') {
|
|
152
|
+
if (typeof value !== 'string') errs.push(`${at}: 期望 string,实到 ${typeof value}`)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const mod = await import(`${INSTALLED}?sweep=${Date.now()}`)
|
|
157
|
+
const registered = []
|
|
158
|
+
mod.apply(mockCtx(registered))
|
|
159
|
+
const T = (n) => {
|
|
160
|
+
const t = registered.find((x) => x.name === n)
|
|
161
|
+
if (!t) throw new Error(`没注册 ${n}`)
|
|
162
|
+
return t
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const today = new Date().toLocaleDateString('sv-SE') // YYYY-MM-DD(本地)
|
|
166
|
+
|
|
167
|
+
// 每个工具至少两条路:走得通的 + 被拒的(**被拒的那条最容易形状不齐**)
|
|
168
|
+
const cases = [
|
|
169
|
+
['coach_ping', {}],
|
|
170
|
+
// 搭建/同步:体检是常走的成功路;不给 handle 走拒绝路(**不联网**)
|
|
171
|
+
['coach_setup', { action: 'status' }],
|
|
172
|
+
['coach_setup', { action: 'sync' }],
|
|
173
|
+
// 记录导入:有记录(成功) / 没记录(拒) / 认不出(跳过计数)
|
|
174
|
+
['coach_import', { records: [{ platform: 'luogu', problem_id: 'P1', verdict: '12' }] }],
|
|
175
|
+
['coach_import', {}],
|
|
176
|
+
['coach_import', { records: [{ platform: 'atcoder', problem_id: 'x', verdict: 'AC' }] }],
|
|
177
|
+
// OI Wiki 检索:四条路都要扫 —— 命中、读页、空结果、参数缺失
|
|
178
|
+
['coach_wiki', { action: 'search', query: '线段树', topK: 3 }],
|
|
179
|
+
['coach_wiki', { action: 'node', node: '线段树' }],
|
|
180
|
+
['coach_wiki', { action: 'page', src: 'ds/seg.md' }],
|
|
181
|
+
['coach_wiki', { action: 'page', src: '没有这个页面.md' }], // 拒:路径不存在
|
|
182
|
+
['coach_wiki', { action: 'search', query: 'zzz查不到的东东qqq' }], // 空结果分支
|
|
183
|
+
['coach_wiki', { action: 'node' }], // 拒:缺参数
|
|
184
|
+
['coach_next', { cursor: 'A' }],
|
|
185
|
+
['coach_next', { cursor: '叶子' }], // ← 兜底分支
|
|
186
|
+
['coach_next', { cursor: '并不存在' }], // 打错字
|
|
187
|
+
['coach_next', {}], // 游标空 → 读夹具
|
|
188
|
+
['coach_status', {}],
|
|
189
|
+
['coach_set_cursor', { node: 'B' }],
|
|
190
|
+
['coach_set_cursor', { node: 'ZZZ' }], // 拒
|
|
191
|
+
['coach_mark', { node: 'C', status: 'studying' }],
|
|
192
|
+
['coach_mark', { node: 'ZZZ', status: 'studying' }], // 拒
|
|
193
|
+
// 开新知识点必须带 teachMinutes —— 所以这条是**成功分支**
|
|
194
|
+
['coach_assign', { node: 'B', deliverable: '把代码贴给我', why: '测试', minutes: 60, teachMinutes: 25 }],
|
|
195
|
+
// 不带 teachMinutes 撞上"全新知识点" → 走拒绝分支(顺便把这条新闸也扫到)
|
|
196
|
+
['coach_assign', { node: 'C', deliverable: '把代码贴给我', why: '测试', minutes: 60 }],
|
|
197
|
+
['coach_assign', { node: 'ZZZ', deliverable: 'x', why: 'y' }], // 拒
|
|
198
|
+
['coach_assign', { node: '缺前置', deliverable: 'x', why: 'y' }], // 拒(不在下一层)
|
|
199
|
+
// ── 押着的训练动作(需求 #6)──
|
|
200
|
+
// 上面 175 那条 assign **成功时已经押上了 B**,所以这三条按实际状态写顺序:
|
|
201
|
+
// 撤掉(成功)→ 再撤一次(拒)→ 撤一个没押着动作的节点(拒)
|
|
202
|
+
['coach_unassign', { node: 'B', reason: '测试:撤掉刚押上的那条' }],
|
|
203
|
+
['coach_unassign', { node: 'B', reason: '再撤一次' }],
|
|
204
|
+
['coach_unassign', { node: 'ZZZ', reason: '不在图上' }],
|
|
205
|
+
['coach_pool', { node: 'A' }],
|
|
206
|
+
['coach_pool', { node: 'ZZZ' }], // 拒
|
|
207
|
+
['coach_schedule', {}],
|
|
208
|
+
['coach_plan', { date: today, blocks: [{ from: '09:00', kind: 'review', node: 'A' }] }],
|
|
209
|
+
['coach_plan', { date: today, blocks: [{ from: '09:00', kind: 'review', node: 'A' }] }], // 已排过 → 拒
|
|
210
|
+
['coach_unplan', { date: today, from: '09:00' }],
|
|
211
|
+
['coach_unplan', { date: today }], // 没给 from/all → 拒
|
|
212
|
+
['coach_diagnose', {
|
|
213
|
+
code: 'int main(){ int a; return 0; }',
|
|
214
|
+
category: '不认真', node: 'B',
|
|
215
|
+
evidence: [{ quote: 'int main(){ int a;', kind: 'wrong', why: '测试用的判据' }],
|
|
216
|
+
summary: '测试判因', problem: '1015D',
|
|
217
|
+
}],
|
|
218
|
+
// ── 后补的两个工具 ──
|
|
219
|
+
// coach_log:上面 coach_plan 那条给今天排了 09:00 的 A,所以这条能算出 plannedMin
|
|
220
|
+
['coach_log', { node: 'A', actualMin: 95, solved: true, independent: false }],
|
|
221
|
+
['coach_log', { node: 'A', actualMin: 70, solved: true, independent: true }], // 覆盖同一天同一点
|
|
222
|
+
['coach_log', { node: 'B', actualMin: 40, solved: false, independent: false }], // 没做出来也要记
|
|
223
|
+
['coach_log', { node: 'ZZZ', actualMin: 30, solved: true, independent: true }], // 拒:不在图上
|
|
224
|
+
['coach_log', { node: 'A', actualMin: 0, solved: true, independent: true }], // 拒:时长非正
|
|
225
|
+
// ── coach_verify(需求 #1 的工具)。它一度不在扫描范围里,
|
|
226
|
+
// 于是出现"工具总数 17、扫到 16"的缺口,而且没报错 ──
|
|
227
|
+
// check:false = 不起网络,形状照样能验(成功 + 拒绝两条路都要扫)
|
|
228
|
+
['coach_verify', { problems: [{ platform: 'nowcoder', problemId: 'NC1',
|
|
229
|
+
gist: '打开题面看过:考的是区间 DP,和别人 AC 代码一致', node: 'A' }], check: false }],
|
|
230
|
+
['coach_verify', { problems: [{ platform: 'nowcoder', problemId: 'NC2', gist: '太短' }], check: false }], // 拒
|
|
231
|
+
// ⚠️ coach_cancel 的两条**不能放在这里** —— 它们是**有状态的**(有没有卷子),
|
|
232
|
+
// 而这一批是在开卷那一坨**之前**全部构造好的:放这儿的话,
|
|
233
|
+
// 循环跑到时卷子已经在手里了,"手里没卷 → 拒"这条就测不到,
|
|
234
|
+
// 两条分支会悄悄换位置。所以挪到下面开卷序列里,顺序写死。
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
// 开卷 → 判卷,得串起来做
|
|
238
|
+
const pool = await T('coach_pool').execute({ node: 'A' }, undefined)
|
|
239
|
+
const picked = (pool.bands ?? []).flatMap((b) => b.candidates ?? b.items ?? []).slice(0, 3)
|
|
240
|
+
if (picked.length >= 3) {
|
|
241
|
+
const paper = await T('coach_test').execute({
|
|
242
|
+
node: 'A',
|
|
243
|
+
problems: [
|
|
244
|
+
{ platform: picked[0].platform, problemId: picked[0].problemId, band: '易' },
|
|
245
|
+
{ platform: picked[1].platform, problemId: picked[1].problemId, band: '中' },
|
|
246
|
+
{ platform: picked[2].platform, problemId: picked[2].problemId, band: '难' },
|
|
247
|
+
],
|
|
248
|
+
}, undefined)
|
|
249
|
+
cases.push(['coach_test#real', null, paper])
|
|
250
|
+
const graded = await T('coach_grade').execute({
|
|
251
|
+
results: picked.slice(0, 3).map((p) => ({ problemId: p.problemId, solved: true, minutes: 15 })),
|
|
252
|
+
}, undefined)
|
|
253
|
+
cases.push(['coach_grade#real', null, graded])
|
|
254
|
+
|
|
255
|
+
// ⚠️ 撤卷是**有状态**的(手里有没有卷),而这一段是"先把卷开好、再放进
|
|
256
|
+
// 用例表",卷子在循环开始前就已经在手里了。第一版在这里先塞了一条
|
|
257
|
+
// "手里没卷→拒",结果它跑到时手上有卷,**两条分支悄悄换了位置**,
|
|
258
|
+
// 输出看着全绿、测的是反的。所以顺序照实际状态写:
|
|
259
|
+
// 开好第二张 → 撤掉(成功)→ 再撤一次(拒)
|
|
260
|
+
const paper2 = await T('coach_test').execute({
|
|
261
|
+
node: 'A',
|
|
262
|
+
problems: [
|
|
263
|
+
{ platform: picked[0].platform, problemId: picked[0].problemId, band: '易' },
|
|
264
|
+
{ platform: picked[1].platform, problemId: picked[1].problemId, band: '中' },
|
|
265
|
+
{ platform: picked[2].platform, problemId: picked[2].problemId, band: '难' },
|
|
266
|
+
],
|
|
267
|
+
}, undefined)
|
|
268
|
+
cases.push(['coach_test#again', null, paper2])
|
|
269
|
+
cases.push(['coach_cancel', { reason: '他说今天没时间做了,明天再说' }]) // 成功:撤掉 paper2
|
|
270
|
+
cases.push(['coach_cancel', { reason: '撤完再撤一次' }]) // 拒:手里已经空了
|
|
271
|
+
} else {
|
|
272
|
+
cases.push(['coach_test', { node: 'A', problems: [] }]) // 拒
|
|
273
|
+
cases.push(['coach_grade', { results: [] }]) // 无卷 → 拒
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── 跑 ─────────────────────────────────────────────────────────
|
|
277
|
+
let bad = 0
|
|
278
|
+
const seen = new Set()
|
|
279
|
+
for (const [name, args, preset] of cases) {
|
|
280
|
+
seen.add(name.split('#')[0])
|
|
281
|
+
const tool = T(name.split('#')[0])
|
|
282
|
+
let out
|
|
283
|
+
try {
|
|
284
|
+
out = preset ?? await tool.execute(args ?? {}, undefined)
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.log(` THROW ${name} → ${err.message}`)
|
|
287
|
+
bad++
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
const errs = []
|
|
291
|
+
validate(tool.output.schema, out, 'out', errs)
|
|
292
|
+
let renderNote = ''
|
|
293
|
+
try {
|
|
294
|
+
const blocks = tool.output.render(args ?? {}, out)
|
|
295
|
+
if (!Array.isArray(blocks) || blocks[0]?.type !== 'text') renderNote = ' render 形状不对'
|
|
296
|
+
} catch (err) {
|
|
297
|
+
renderNote = ` render 抛错:${err.message}`
|
|
298
|
+
}
|
|
299
|
+
if (errs.length || renderNote) {
|
|
300
|
+
bad++
|
|
301
|
+
console.log(` FAIL ${name}${renderNote}`)
|
|
302
|
+
for (const e of errs.slice(0, 6)) console.log(` ${e}`)
|
|
303
|
+
if (errs.length > 6) console.log(` …还有 ${errs.length - 6} 条`)
|
|
304
|
+
} else {
|
|
305
|
+
const n = out.ok === false || out.accepted === false ? '(走拒绝分支)' : ''
|
|
306
|
+
console.log(` OK ${name}${n}`)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const all = registered.map((t) => t.name)
|
|
311
|
+
const missing = all.filter((n) => !seen.has(n))
|
|
312
|
+
console.log()
|
|
313
|
+
console.log(`工具总数 ${all.length},扫到 ${seen.size}${missing.length ? `,没扫到:${missing.join(', ')}` : ''}`)
|
|
314
|
+
console.log(bad ? `✗ ${bad} 条用例形状不合格` : '✓ 所有用例的形状都和 schema 对齐')
|
|
315
|
+
process.exit(bad ? 1 : 0)
|