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.
Files changed (50) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +258 -0
  3. package/assets/extension/luogu/README.md +104 -0
  4. package/assets/extension/luogu/content.js +137 -0
  5. package/assets/extension/luogu/manifest.json +22 -0
  6. package/assets/knowledge/DEPENDS.yaml +391 -0
  7. package/assets/knowledge/LICENSE +41 -0
  8. package/assets/knowledge/MAP.yaml +2540 -0
  9. package/assets/knowledge/NODE_ENTRY.yaml +465 -0
  10. package/assets/knowledge/NODE_META.yaml +457 -0
  11. package/assets/knowledge/skilltree.html +1702 -0
  12. package/assets/oiwiki/LICENSE +49 -0
  13. package/assets/oiwiki/chunks.jsonl +2177 -0
  14. package/assets/oiwiki/node-pages.json +1208 -0
  15. package/assets/pool/LICENSE +83 -0
  16. package/assets/pool/problems.jsonl.gz +0 -0
  17. package/assets/rules/coach-rules.md +425 -0
  18. package/build-node-meta.mjs +98 -0
  19. package/coach/analyze_map.py +138 -0
  20. package/coach/build_map.py +230 -0
  21. package/coach/build_node_pages.py +105 -0
  22. package/coach/check_page_syntax.mjs +48 -0
  23. package/coach/coach-metrics.py +313 -0
  24. package/coach/export_oiwiki_chunks.py +100 -0
  25. package/coach/export_problem_pool.py +64 -0
  26. package/coach/extract_oiwiki.py +110 -0
  27. package/coach/oiwiki_tree.json +2987 -0
  28. package/coach/render_map.py +226 -0
  29. package/coach/render_skilltree.py +197 -0
  30. package/coach/serve_map.py +181 -0
  31. package/coach/skilltree_template.html +1702 -0
  32. package/coach/validate_map.py +184 -0
  33. package/coach/verify_drag.mjs +51 -0
  34. package/coach/verify_interact.mjs +82 -0
  35. package/coach/verify_serve.py +138 -0
  36. package/cordis.patch.yml +10 -0
  37. package/dev/parity-check.mjs +169 -0
  38. package/dev/run-all.sh +70 -0
  39. package/dev/setup-check.mjs +191 -0
  40. package/dev/w1-check.mjs +170 -0
  41. package/dev/wiki-check.mjs +100 -0
  42. package/dev.sh +142 -0
  43. package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +325 -0
  44. package/index.js +5820 -0
  45. package/lib/client.js +209 -0
  46. package/lib/setup.js +455 -0
  47. package/package.json +33 -0
  48. package/progress-cli.mjs +67 -0
  49. package/verify-shape.mjs +315 -0
  50. package/verify.mjs +3535 -0
package/lib/client.js ADDED
@@ -0,0 +1,209 @@
1
+ // acmer-coach 浏览器端:会话头部一个「技能树」按钮,点开是全屏技能树。
2
+ //
3
+ // ── 为什么是「弹一层 iframe」而不是把图重画一遍 ──────────────────────
4
+ // 技能树页面(knowledge/skilltree.html)是 render_skilltree.py 从 MAP.yaml
5
+ // 生成的静态页,349 个节点 401 条边。在 React 里重画一遍 = 同一张图两份实现,
6
+ // 而这两份迟早会不一致(改了一处忘另一处)。
7
+ // iframe 直接复用那一份,页面自己从 ./api/progress 读进度。
8
+ //
9
+ // 页面的提供方是 **host 端的 /coach 路由**(index.js 的 registerPages)——
10
+ // 同源,所以 iframe 不需要处理跨域,也不用另起一个 Python 进程。
11
+ //
12
+ // ── 形状是照抄谁的 ───────────────────────────────────────────────
13
+ // @deepseek-ai/dsh-session-log-export(官方包,同一个 dsh 版本):
14
+ // 同样是「header.utilities 里一个按钮 + 一个浮层」。
15
+ // 差异只有一处:它用 primitives 的 Modal/Button,那两个组件是官方的
16
+ // 内部包(@deepseek-ai/dsh-client-ui-primitives 不在第三方能 require 的
17
+ // 名单里),所以浮层和样式这里自己写 —— 颜色全走 dsh 的 CSS 变量,
18
+ // 换主题自动跟着走。
19
+ //
20
+ // ⚠️ factory 必须 return {apply, inject}。少了它 slot 系统会拿到 undefined,
21
+ // 报错信息是 "received undefined",跟根因隔了十万八千里(踩过一次)。
22
+ window.__ModuleLoader__.load({
23
+ id: "acmer-coach",
24
+ factory: (require) => {
25
+ const React = require("react");
26
+ const { jsx, jsxs } = require("react/jsx-runtime");
27
+ const { createPortal } = require("react-dom");
28
+
29
+ const CSS_ID = "acmer-coach-skilltree-css";
30
+ const CSS = `
31
+ .dshcoach-btn {
32
+ display: inline-flex; align-items: center; gap: 4px;
33
+ height: 32px; padding: 6px 12px; min-width: 0;
34
+ font-family: var(--dsw-font-family, inherit); font-size: 13px; font-weight: 400;
35
+ line-height: 20px; white-space: nowrap;
36
+ color: var(--dsw-alias-label-primary, #e6edf3);
37
+ background: transparent; cursor: pointer;
38
+ border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15));
39
+ border-radius: 18px;
40
+ }
41
+ .dshcoach-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
42
+ .dshcoach-btn span, .dshcoach-btn svg { flex: none; }
43
+
44
+ .dshcoach-mask {
45
+ position: fixed; inset: 0; z-index: 9999;
46
+ display: flex; align-items: center; justify-content: center;
47
+ background: rgba(0, 0, 0, .55);
48
+ backdrop-filter: blur(2px);
49
+ }
50
+ .dshcoach-panel {
51
+ display: flex; flex-direction: column; overflow: hidden;
52
+ width: min(1400px, 94vw); height: 90vh;
53
+ background: var(--dsw-alias-bg-base, #0d1117);
54
+ border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15));
55
+ border-radius: 12px;
56
+ box-shadow: 0 16px 48px rgba(0, 0, 0, .5);
57
+ }
58
+ .dshcoach-bar {
59
+ display: flex; align-items: center; gap: 8px; flex: none;
60
+ padding: 10px 14px;
61
+ border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.08));
62
+ }
63
+ .dshcoach-title {
64
+ display: flex; align-items: center; gap: 7px;
65
+ flex: 1; min-width: 0;
66
+ font-family: var(--dsw-font-family, inherit); font-size: 14px;
67
+ color: var(--dsw-alias-label-primary, #e6edf3);
68
+ }
69
+ .dshcoach-title svg { flex: none; }
70
+ .dshcoach-action {
71
+ font: inherit; font-size: 12px; line-height: 18px;
72
+ color: var(--dsw-alias-label-secondary, #8b949e);
73
+ background: transparent; cursor: pointer;
74
+ border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.15));
75
+ border-radius: 6px; padding: 3px 10px;
76
+ }
77
+ .dshcoach-action:hover { color: var(--dsw-alias-label-primary, #e6edf3); }
78
+ .dshcoach-frame { flex: 1; width: 100%; border: 0; display: block; }
79
+ `;
80
+
81
+ function injectStyle() {
82
+ if (document.getElementById(CSS_ID)) return;
83
+ const style = document.createElement("style");
84
+ style.id = CSS_ID;
85
+ style.textContent = CSS;
86
+ document.head.appendChild(style);
87
+ }
88
+
89
+ const PAGE_URL = "/coach/skilltree.html";
90
+
91
+ // VP(CompetitionWhale)的品牌标:**拱**是露出水面的鲸背,**波浪**是水面,两笔。
92
+ //
93
+ // 为什么不是 emoji:🐋 在不同系统上画风不一(Windows 是蓝色写实鲸、Mac 是另一个样),
94
+ // 而且它自带颜色 —— 深色/浅色主题切换时不会跟着走。线框 SVG 走 currentColor,
95
+ // 主题怎么变它怎么变。
96
+ //
97
+ // 形状是照着一张位图标描的(338×72 的鲸鱼 logo 截图):
98
+ // 把图标区域二值化后按**原始分辨率**打成字符网格读出控制点 ——
99
+ // 拱顶在 (16.5,3)、两腿撇到 (7.5,11)/(25.5,11);波浪是
100
+ // 起(2,17.5) → 峰(9,14) → 谷(16,17.5) → 峰(23,14) → 收(30,17.5)。
101
+ // 两腿往外撇(像「人」字)是描图时才看出来的细节,垂直腿一眼就假。
102
+ function WhaleMark({ size = 15 }) {
103
+ return jsxs("svg", {
104
+ width: Math.round(size * (34 / 22)),
105
+ height: size,
106
+ viewBox: "0 0 34 22",
107
+ fill: "none",
108
+ stroke: "currentColor",
109
+ strokeWidth: 2.6,
110
+ strokeLinecap: "round",
111
+ strokeLinejoin: "round",
112
+ "aria-hidden": "true",
113
+ focusable: "false",
114
+ children: [
115
+ jsx("path", { d: "M7.5 11 C8.5 5.5 11.5 3 16.5 3 C21.5 3 24.5 5.5 25.5 11" }),
116
+ jsx("path", { d: "M2 17.5 C5.5 17.5 6 14 9 14 C12 14 12.8 17.5 16 17.5 C19.2 17.5 20 14 23 14 C26 14 26.5 17.5 30 17.5" }),
117
+ ],
118
+ });
119
+ }
120
+
121
+ /** 全屏浮层:一条窄栏 + 撑满的 iframe。 */
122
+ function SkillTreeOverlay({ onClose }) {
123
+ React.useEffect(() => {
124
+ const onKey = (e) => { if (e.key === "Escape") onClose(); };
125
+ document.addEventListener("keydown", onKey);
126
+ return () => document.removeEventListener("keydown", onKey);
127
+ }, [onClose]);
128
+
129
+ return jsx("div", {
130
+ className: "dshcoach-mask",
131
+ // 点背景关(点面板内部不关 —— 判据是 target 就是遮罩本身)
132
+ onClick: (e) => { if (e.target === e.currentTarget) onClose(); },
133
+ children: jsxs("div", {
134
+ className: "dshcoach-panel",
135
+ children: [
136
+ jsxs("div", {
137
+ className: "dshcoach-bar",
138
+ children: [
139
+ jsxs("div", {
140
+ className: "dshcoach-title",
141
+ children: [jsx(WhaleMark, { size: 17 }), jsx("span", { children: "技能树" })],
142
+ }),
143
+ jsx("button", {
144
+ type: "button",
145
+ className: "dshcoach-action",
146
+ onClick: () => window.open(PAGE_URL, "_blank", "noopener"),
147
+ children: "新标签页打开",
148
+ }),
149
+ jsx("button", {
150
+ type: "button",
151
+ className: "dshcoach-action",
152
+ onClick: onClose,
153
+ children: "✕ 关闭",
154
+ }),
155
+ ],
156
+ }),
157
+ jsx("iframe", {
158
+ className: "dshcoach-frame",
159
+ src: PAGE_URL,
160
+ title: "技能树",
161
+ }),
162
+ ],
163
+ }),
164
+ });
165
+ }
166
+
167
+ /** 头部那个按钮 + 它开出来的浮层。 */
168
+ function SkillTreeHeaderButton() {
169
+ const [open, setOpen] = React.useState(false);
170
+ return jsxs(React.Fragment, {
171
+ children: [
172
+ jsxs("button", {
173
+ type: "button",
174
+ className: "dshcoach-btn",
175
+ title: "看当前知识点地图与进度",
176
+ onClick: () => setOpen(true),
177
+ children: [jsx(WhaleMark, { size: 15 }), jsx("span", { children: "技能树" })],
178
+ }),
179
+ // 关掉 = 卸载。iframe 跟着销毁,下次开是新拉的页面 ——
180
+ // 进度是页面向 host 要的,不会因此读到过期数据。
181
+ //
182
+ // ⚠️ 必须 portal 到 body,**不能**就地渲染。踩过:header 元素上挂着
183
+ // `transform: matrix(1,0,0,1,0,0)`(看着"没位移",但它照样建立了
184
+ // containing block),于是 `position: fixed` 的遮罩改成相对 **header**
185
+ // 定位 —— 755px 高的面板在 75px 高的 header 里居中,y 算出来是 -340,
186
+ // 整个弹层跑到屏幕外面去了。CDP 实测:panel y=-340。
187
+ // 这是 CSS 的规矩,不是 dsh 的 bug:transform 非 none 的祖先就是
188
+ // fixed 后代的包含块。portal 到 body 是标准解法。
189
+ open ? createPortal(jsx(SkillTreeOverlay, { onClose: () => setOpen(false) }), document.body) : null,
190
+ ],
191
+ });
192
+ }
193
+
194
+ // 服务名注入(不是模块名):等 slots 服务到位再注册。
195
+ const inject = ["slots"];
196
+
197
+ function apply(ctx) {
198
+ injectStyle();
199
+ ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
200
+ name: "conversation.session.header.utilities",
201
+ id: "acmer-coach:skilltree",
202
+ order: 20,
203
+ registrant: "acmer-coach",
204
+ }, SkillTreeHeaderButton));
205
+ }
206
+
207
+ return { apply, inject };
208
+ },
209
+ });
package/lib/setup.js ADDED
@@ -0,0 +1,455 @@
1
+ // 环境搭建 + Codeforces 数据同步。
2
+ //
3
+ // 单独一个文件而不是塞进 index.js:这块和教练逻辑**不共享状态** ——
4
+ // 它只往磁盘上放数据,不知道什么叫"节点""游标""检测"。混在一起会让
5
+ // index.js 变成什么都管。
6
+ //
7
+ // ⚠️ 关于「拉数据算掌握度」——**没有这回事**,别在这里加。
8
+ // 试过,证伪了:一个人某题提交 11 次,数据能说"花了很久",
9
+ // 说不出"卡在哪"。所以这里只做**事实搬运**:把 CF 上的公开记录搬进本地库,
10
+ // 至于"他会不会",那是检测的事(见 index.js 的 coach_pool/test/grade)。
11
+ // 同步完给的「接触证据」只是**缩小搜索范围**用的,不是结论。
12
+ import { mkdirSync, existsSync, copyFileSync, readdirSync, readFileSync } from 'node:fs'
13
+ import { gunzipSync } from 'node:zlib'
14
+ import { join } from 'node:path'
15
+
16
+ // ── 表结构 ────────────────────────────────────────────────────────
17
+ // 逐列对齐既有训练库:插件里的查询是按这些列名写的,改列名等于改查询。
18
+ export const SCHEMA = `
19
+ CREATE TABLE IF NOT EXISTS users (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ handle TEXT NOT NULL UNIQUE,
22
+ display_name TEXT NOT NULL,
23
+ cf_handle TEXT,
24
+ luogu_uid TEXT,
25
+ nowcoder_handle TEXT
26
+ );
27
+ CREATE TABLE IF NOT EXISTS unified_submissions (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ user_id INTEGER NOT NULL REFERENCES users(id),
30
+ platform TEXT NOT NULL CHECK(platform IN ('codeforces','luogu','nowcoder')),
31
+ problem_id TEXT NOT NULL,
32
+ verdict TEXT NOT NULL DEFAULT 'Other',
33
+ score INTEGER DEFAULT 0,
34
+ time_ms INTEGER DEFAULT 0,
35
+ memory_kb INTEGER DEFAULT 0,
36
+ language TEXT DEFAULT '',
37
+ submitted_at TEXT,
38
+ cf_sub_id INTEGER,
39
+ lg_sub_id INTEGER,
40
+ nc_sub_id INTEGER
41
+ );
42
+ CREATE TABLE IF NOT EXISTS unified_problems (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ platform TEXT NOT NULL CHECK(platform IN ('codeforces','luogu','nowcoder')),
45
+ problem_id TEXT NOT NULL,
46
+ title TEXT DEFAULT '',
47
+ difficulty REAL DEFAULT 0,
48
+ tags TEXT DEFAULT '',
49
+ status TEXT DEFAULT '',
50
+ solved_date TEXT,
51
+ url TEXT DEFAULT '',
52
+ is_library INTEGER DEFAULT 0,
53
+ UNIQUE(platform, problem_id)
54
+ );
55
+ CREATE TABLE IF NOT EXISTS rating_history (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ user_id INTEGER NOT NULL REFERENCES users(id),
58
+ rating INTEGER DEFAULT 0,
59
+ rank TEXT DEFAULT '',
60
+ max_rating INTEGER DEFAULT 0,
61
+ recorded_at TEXT NOT NULL,
62
+ platform TEXT NOT NULL DEFAULT 'codeforces'
63
+ );
64
+ CREATE TABLE IF NOT EXISTS diff_rating_map (
65
+ platform TEXT NOT NULL, diff INTEGER NOT NULL, cf_rating INTEGER NOT NULL,
66
+ PRIMARY KEY (platform, diff)
67
+ );
68
+ CREATE INDEX IF NOT EXISTS idx_sub_user_plat ON unified_submissions(user_id, platform, problem_id);
69
+ CREATE INDEX IF NOT EXISTS idx_prob_lookup ON unified_problems(platform, problem_id);
70
+ `
71
+
72
+ // 跨平台难度归一化:各家的难度档 → 大致等价的 CF rating。
73
+ // 13 行,是人类共识不是精确换算 —— 但它让"牛客 3 档"和"CF 1600"能比。
74
+ export const DIFF_RATING_MAP = [
75
+ ['luogu', 1, 1000], ['luogu', 2, 1300], ['luogu', 3, 1500], ['luogu', 4, 1700],
76
+ ['luogu', 5, 2000], ['luogu', 6, 2300], ['luogu', 7, 2600], ['luogu', 8, 3000],
77
+ ['nowcoder', 1, 800], ['nowcoder', 2, 1200], ['nowcoder', 3, 1600],
78
+ ['nowcoder', 4, 2000], ['nowcoder', 5, 2400],
79
+ ]
80
+
81
+ // CF 的 verdict → 本库统一用词。插件判 AC 认的是 `'AC'`,**不是** CF 的 `'OK'** ——
82
+ // 不映射的话 AC 集合永远是空的,而症状是"已 AC 的题还在被推"。
83
+ const CF_VERDICT = {
84
+ OK: 'AC',
85
+ WRONG_ANSWER: 'WA',
86
+ TIME_LIMIT_EXCEEDED: 'TLE',
87
+ MEMORY_LIMIT_EXCEEDED: 'MLE',
88
+ RUNTIME_ERROR: 'RE',
89
+ COMPILATION_ERROR: 'CE',
90
+ IDLENESS_LIMIT_EXCEEDED: 'ILE',
91
+ PRESENTATION_ERROR: 'PE',
92
+ FAILED: 'FAILED',
93
+ SKIPPED: 'SKIPPED',
94
+ SECURITY_VIOLATED: 'SECURITY',
95
+ CRASHED: 'CRASHED',
96
+ REJECTED: 'REJECTED',
97
+ }
98
+
99
+ const CF_API = 'https://codeforces.com/api'
100
+
101
+ async function cfGet(path) {
102
+ const res = await fetch(`${CF_API}/${path}`, {
103
+ headers: { 'User-Agent': 'acmer-coach/1.0 (training coach plugin)' },
104
+ signal: AbortSignal.timeout(30000),
105
+ })
106
+ if (!res.ok) throw new Error(`CF API HTTP ${res.status}`)
107
+ const body = await res.json()
108
+ if (body.status !== 'OK') throw new Error(`CF API:${body.comment ?? '未知错误'}`)
109
+ return body.result
110
+ }
111
+
112
+ /** 建表 + 灌难度映射表。幂等,可以反复跑。 */
113
+ export function ensureSchema(db) {
114
+ db.exec(SCHEMA)
115
+ const ins = db.prepare(
116
+ 'INSERT OR REPLACE INTO diff_rating_map (platform, diff, cf_rating) VALUES (?,?,?)')
117
+ for (const [p, d, r] of DIFF_RATING_MAP) ins.run(p, d, r)
118
+ }
119
+
120
+ /**
121
+ * 搭一个能用的数据目录:建目录、把地图拷过去、建库。
122
+ * 已存在的东西**不覆盖** —— 用户的进度比我们的默认值重要。
123
+ */
124
+ export function initDataDir({ dataDir, dbPath, assetsDir, DatabaseSync }) {
125
+ const made = { dir: false, knowledge: [], db: false }
126
+ if (!existsSync(dataDir)) { mkdirSync(dataDir, { recursive: true }); made.dir = true }
127
+
128
+ const src = join(assetsDir, 'knowledge')
129
+ if (existsSync(src)) {
130
+ for (const f of readdirSync(src)) {
131
+ if (!/\.(yaml|yml|html)$/.test(f)) continue // 别把 LICENSE 拷进数据目录
132
+ const dst = join(dataDir, f)
133
+ if (existsSync(dst)) continue // 不覆盖已有的
134
+ copyFileSync(join(src, f), dst)
135
+ made.knowledge.push(f)
136
+ }
137
+ }
138
+
139
+ const fresh = !existsSync(dbPath)
140
+ mkdirSync(join(dbPath, '..'), { recursive: true })
141
+ const db = new DatabaseSync(dbPath)
142
+ try { ensureSchema(db) } finally { db.close() }
143
+ made.db = fresh
144
+ return made
145
+ }
146
+
147
+ /**
148
+ * 从 CF 拉公开数据灌进本地库。**不需要登录、不需要 API key** ——
149
+ * 用的都是 CF 的公开端点。
150
+ *
151
+ * @param opts.dbPath 训练库路径
152
+ * @param opts.handle CF handle
153
+ * @param opts.limit 最多拉多少条提交(默认全拉)
154
+ * @param opts.problemset 是否同时拉全题库(coach_pool 的候选池靠它)
155
+ * @param opts.onStep 进度回调
156
+ */
157
+ export async function syncCodeforces({ dbPath, handle, limit = 0, problemset = true, onStep = () => {}, DatabaseSync }) {
158
+ const h = String(handle ?? '').trim()
159
+ if (!h) throw new Error('没给 CF handle')
160
+
161
+ const db = new DatabaseSync(dbPath)
162
+ const stat = { user: null, submissions: 0, problems: 0, library: 0, rating: 0, note: '' }
163
+ try {
164
+ ensureSchema(db)
165
+
166
+ // ── 1. 账号 + 当前 rating ─────────────────────────────────────
167
+ onStep('拉账号信息…')
168
+ const [info] = await cfGet(`user.info?handles=${encodeURIComponent(h)}`)
169
+ if (!info) throw new Error(`CF 上查不到 handle「${h}」`)
170
+ db.prepare(
171
+ `INSERT INTO users (handle, display_name, cf_handle) VALUES (?,?,?)
172
+ ON CONFLICT(handle) DO UPDATE SET cf_handle = excluded.cf_handle`).run(h, h, h)
173
+ const uid = db.prepare('SELECT id FROM users WHERE handle = ?').get(h).id
174
+ stat.user = { id: uid, handle: h, rating: info.rating ?? 0, maxRating: info.maxRating ?? 0, rank: info.rank ?? '' }
175
+
176
+ if (info.rating) {
177
+ db.prepare(
178
+ `INSERT INTO rating_history (user_id, rating, rank, max_rating, recorded_at, platform)
179
+ VALUES (?,?,?,?,?, 'codeforces')`)
180
+ .run(uid, info.rating, info.rank ?? '', info.maxRating ?? 0, new Date().toISOString())
181
+ stat.rating = 1
182
+ }
183
+
184
+ // ── 2. 提交记录 ───────────────────────────────────────────────
185
+ // CF 的 user.status 分页给,一次最多 10000 条。倒着拉(新的在前)。
186
+ const insSub = db.prepare(
187
+ `INSERT OR IGNORE INTO unified_submissions
188
+ (user_id, platform, problem_id, verdict, time_ms, memory_kb, language, submitted_at, cf_sub_id)
189
+ VALUES (?,?,?,?,?,?,?,?,?)`)
190
+ const insProb = db.prepare(
191
+ `INSERT INTO unified_problems (platform, problem_id, title, difficulty, tags, url, is_library)
192
+ VALUES ('codeforces', ?, ?, ?, ?, ?, 0)
193
+ ON CONFLICT(platform, problem_id) DO UPDATE SET
194
+ title = excluded.title, difficulty = excluded.difficulty,
195
+ tags = excluded.tags, url = excluded.url`)
196
+
197
+ const PAGE = 5000
198
+ let from = 1, done = false
199
+ const seenProblems = new Map()
200
+ while (!done) {
201
+ onStep(`拉提交记录 ${from}…`)
202
+ const batch = await cfGet(`user.status?handle=${encodeURIComponent(h)}&from=${from}&count=${PAGE}`)
203
+ if (!batch.length) break
204
+ db.exec('BEGIN')
205
+ try {
206
+ for (const s of batch) {
207
+ const p = s.problem ?? {}
208
+ if (p.contestId === undefined || !p.index) continue // 非标准题(有些 gym 题没有)
209
+ const pid = `${p.contestId}${p.index}`
210
+ const at = new Date((s.creationTimeSeconds ?? 0) * 1000).toISOString()
211
+ insSub.run(
212
+ uid, 'codeforces', pid, CF_VERDICT[s.verdict] ?? s.verdict ?? 'Other',
213
+ s.timeConsumedMillis ?? 0, Math.round((s.memoryConsumedBytes ?? 0) / 1024),
214
+ s.programmingLanguage ?? '', at, s.id ?? null)
215
+ stat.submissions++
216
+ if (!seenProblems.has(pid)) seenProblems.set(pid, p)
217
+ }
218
+ db.exec('COMMIT')
219
+ } catch (e) { db.exec('ROLLBACK'); throw e }
220
+
221
+ if (batch.length < PAGE) done = true
222
+ from += PAGE
223
+ if (limit && stat.submissions >= limit) done = true
224
+ }
225
+
226
+ // 提交里带出来的题目元信息(CF 会一并返回 tags / rating)
227
+ db.exec('BEGIN')
228
+ try {
229
+ for (const [pid, p] of seenProblems) {
230
+ insProb.run(pid, p.name ?? '', p.rating ?? 0, (p.tags ?? []).join(','),
231
+ `https://codeforces.com/contest/${p.contestId}/problem/${p.index}`)
232
+ stat.problems++
233
+ }
234
+ db.exec('COMMIT')
235
+ } catch (e) { db.exec('ROLLBACK'); throw e }
236
+
237
+ // ── 3. 全题库 → coach_pool 的候选池 ───────────────────────────
238
+ // 只拉一次,11k 道。**这是"出卷有题可挑"的前提** ——
239
+ // 没有它,教练只能从你做过的那几道里挑,"没做过"这个条件永远满足不了。
240
+ if (problemset) {
241
+ onStep('拉全题库(约 11k 道,一次性)…')
242
+ const { problems } = await cfGet('problemset.problems')
243
+ db.exec('BEGIN')
244
+ try {
245
+ for (const p of problems) {
246
+ if (p.contestId === undefined || !p.index) continue
247
+ const pid = `${p.contestId}${p.index}`
248
+ db.prepare(
249
+ `INSERT INTO unified_problems (platform, problem_id, title, difficulty, tags, url, is_library)
250
+ VALUES ('codeforces', ?, ?, ?, ?, ?, 1)
251
+ ON CONFLICT(platform, problem_id) DO UPDATE SET
252
+ title = excluded.title, difficulty = excluded.difficulty,
253
+ tags = excluded.tags, url = excluded.url, is_library = 1`)
254
+ .run(pid, p.name ?? '', p.rating ?? 0, (p.tags ?? []).join(','),
255
+ `https://codeforces.com/contest/${p.contestId}/problem/${p.index}`)
256
+ stat.library++
257
+ }
258
+ db.exec('COMMIT')
259
+ } catch (e) { db.exec('ROLLBACK'); throw e }
260
+ }
261
+
262
+ return { ...stat, dbPath }
263
+ } finally {
264
+ db.close()
265
+ }
266
+ }
267
+
268
+ // ── 题池导入 ──────────────────────────────────────────────────────
269
+ //
270
+ // 随包发的那份题库(41k 道,三个平台)。**没有它 `coach_pool` 就是废的** ——
271
+ // 挑候选要满足「他没做过」这个条件,而没有题池时候选只能从他做过的那几道里挑,
272
+ // 那个条件永远满足不了。
273
+ //
274
+ // 什么时候导入:`coach_setup init` 建完库顺手灌(首次用就有题可挑)。
275
+ // 幂等:`INSERT ... DO NOTHING`,重复跑不会翻倍。
276
+ export function importPool({ dbPath, poolPath, DatabaseSync, onStep = () => {} }) {
277
+ if (!existsSync(poolPath)) return { imported: 0, note: '没找到题库文件,coach_pool 会缺候选' }
278
+ onStep('灌题库…')
279
+ const raw = gunzipSync(readFileSync(poolPath)).toString('utf8')
280
+ const db = new DatabaseSync(dbPath)
281
+ try {
282
+ ensureSchema(db)
283
+ const ins = db.prepare(
284
+ `INSERT INTO unified_problems (platform, problem_id, title, difficulty, tags, url, is_library)
285
+ VALUES (?,?,?,?,?,?,1)
286
+ ON CONFLICT(platform, problem_id) DO UPDATE SET
287
+ title = excluded.title, difficulty = excluded.difficulty,
288
+ tags = excluded.tags, url = excluded.url, is_library = 1`)
289
+ let n = 0
290
+ db.exec('BEGIN')
291
+ try {
292
+ for (const line of raw.split('\n')) {
293
+ if (!line) continue
294
+ const r = JSON.parse(line)
295
+ ins.run(r.p, r.i, r.t ?? '', r.d ?? 0, r.g ?? '', r.u ?? '')
296
+ n++
297
+ }
298
+ db.exec('COMMIT')
299
+ } catch (e) { db.exec('ROLLBACK'); throw e }
300
+ return { imported: n, note: '' }
301
+ } finally {
302
+ db.close()
303
+ }
304
+ }
305
+
306
+ // ── 记录导入 ──────────────────────────────────────────────────────
307
+ //
308
+ // 通用入口:不管记录从哪来(浏览器扩展导出的文件、自己写的脚本、
309
+ // 手敲的几行),都走这一个函数落库。
310
+ //
311
+ // **为什么要有这个**:CF 有公开接口,一条命令就能同步;但洛谷和牛客的
312
+ // 提交记录**都要登录态**,没有公开接口。与其给每个平台写一套爬虫
313
+ // (那是要维护、会失效、而且有风险的东西),不如定义**一种格式**,
314
+ // 让数据自己找上门来。
315
+ //
316
+ // 格式(JSON 数组或 JSONL,每行一个):
317
+ // { "platform": "luogu", "problem_id": "P1001",
318
+ // "verdict": "AC", "submitted_at": "2026-09-01T12:00:00+08:00",
319
+ // "time_ms": 0, "language": "C++", "sub_id": 123 }
320
+ // 只有 platform / problem_id / verdict 是必需的。
321
+ const PLATFORMS = new Set(['codeforces', 'luogu', 'nowcoder'])
322
+
323
+ /**
324
+ * 归一化一条记录。认不出来就返回 null(调用方跳过并计数)。
325
+ *
326
+ * ⚠️ 洛谷的 verdict:只认 `12 → AC`,其余一律 `Other`。
327
+ * 细分的状态码(TLE/WA/…)**没有映射** —— 那需要登录态才能核实,
328
+ * 与其猜一个可能错的映射表,不如只保留能被依赖的那一个判断。
329
+ * 教练真正用到的也只有这个:`acSet()` 只查 `verdict = 'AC'`。
330
+ */
331
+ export function normalizeRecord(raw) {
332
+ if (!raw || typeof raw !== 'object') return null
333
+ const platform = String(raw.platform ?? '').toLowerCase().trim()
334
+ if (!PLATFORMS.has(platform)) return null
335
+
336
+ // 题号:CF 是 `1015D`,洛谷是 `P1001`,牛客 `NC12345`。
337
+ // 牛客的 `NC` 前缀要剥掉 —— 插件里 normId() 就是这么认的,两边得一致。
338
+ let pid = String(raw.problem_id ?? raw.problemId ?? '').trim()
339
+ if (!pid) return null
340
+ if (platform === 'nowcoder') pid = pid.replace(/^NC/i, '')
341
+
342
+ let verdict = String(raw.verdict ?? raw.status ?? '').trim()
343
+ if (platform === 'luogu') {
344
+ verdict = verdict === '12' || verdict === 'AC' ? 'AC' : 'Other'
345
+ } else if (verdict === 'OK') {
346
+ verdict = 'AC' // CF 的写法
347
+ } else if (!verdict) {
348
+ verdict = 'Other'
349
+ }
350
+
351
+ let at = raw.submitted_at ?? raw.submitTime ?? raw.time ?? null
352
+ if (typeof at === 'number') at = new Date(at < 1e12 ? at * 1000 : at).toISOString()
353
+ if (typeof at !== 'string' || !at) at = new Date().toISOString()
354
+
355
+ return {
356
+ platform, problem_id: pid, verdict, submitted_at: at,
357
+ time_ms: Number(raw.time_ms ?? 0) || 0,
358
+ language: String(raw.language ?? '').slice(0, 64),
359
+ sub_id: raw.sub_id ?? raw.id ?? null,
360
+ }
361
+ }
362
+
363
+ /**
364
+ * 把一批记录写进库。**按题去重**而不是按提交去重 ——
365
+ * 同一道题提交十次只留一条 AC(和一条最近的非 AC),
366
+ * 否则导入一份「全部提交记录」会把 AC 集合淹在噪音里。
367
+ */
368
+ export function importRecords({ dbPath, records, handle = '', DatabaseSync }) {
369
+ const clean = []
370
+ let bad = 0
371
+ for (const r of records ?? []) {
372
+ const n = normalizeRecord(r)
373
+ if (n) clean.push(n); else bad++
374
+ }
375
+ if (!clean.length) return { inserted: 0, skipped: bad, byPlatform: {}, note: '没有能识别的记录' }
376
+
377
+ const db = new DatabaseSync(dbPath)
378
+ try {
379
+ ensureSchema(db)
380
+ const h = String(handle || 'trainer').trim() || 'trainer'
381
+ db.prepare(
382
+ `INSERT INTO users (handle, display_name, cf_handle) VALUES (?,?,?)
383
+ ON CONFLICT(handle) DO NOTHING`).run(h, h, h)
384
+ const uid = db.prepare('SELECT id FROM users WHERE handle = ?').get(h).id
385
+
386
+ // 已有的 AC 集合:导入是**增量**的,不推翻既有判断
387
+ const have = new Set(db.prepare(
388
+ "SELECT platform || '|' || problem_id FROM unified_submissions WHERE user_id = ? AND verdict = 'AC'")
389
+ .all(uid).map((r) => Object.values(r)[0]))
390
+
391
+ const insSub = db.prepare(
392
+ `INSERT INTO unified_submissions
393
+ (user_id, platform, problem_id, verdict, time_ms, language, submitted_at)
394
+ VALUES (?,?,?,?,?,?,?)`)
395
+ const insProb = db.prepare(
396
+ `INSERT INTO unified_problems (platform, problem_id, title, difficulty, tags, url, is_library)
397
+ VALUES (?,?, '', 0, '', '', 0) ON CONFLICT(platform, problem_id) DO NOTHING`)
398
+
399
+ const by = {}
400
+ let inserted = 0
401
+ db.exec('BEGIN')
402
+ try {
403
+ for (const r of clean) {
404
+ const key = `${r.platform}|${r.problem_id}`
405
+ // 已经有 AC 的题,非 AC 记录不覆盖(那只会把"过了"改成"没过")
406
+ if (have.has(key) && r.verdict !== 'AC') continue
407
+ insSub.run(uid, r.platform, r.problem_id, r.verdict, r.time_ms, r.language, r.submitted_at)
408
+ insProb.run(r.platform, r.problem_id)
409
+ if (r.verdict === 'AC') have.add(key)
410
+ by[r.platform] = (by[r.platform] ?? 0) + 1
411
+ inserted++
412
+ }
413
+ db.exec('COMMIT')
414
+ } catch (e) { db.exec('ROLLBACK'); throw e }
415
+
416
+ return { inserted, skipped: bad, byPlatform: by, uid }
417
+ } finally {
418
+ db.close()
419
+ }
420
+ }
421
+
422
+ /**
423
+ * 接触证据:**不是掌握度**,是"他碰过没有"。
424
+ *
425
+ * 用途只有一个 —— 冷启动时缩小搜索范围,好让第一次检测落在不太离谱的地方。
426
+ * 不要拿它下结论:AC 过 20 道 dp 题说明"他见过 dp",
427
+ * 不说明"他会区间 DP" —— 后者只能靠检测。
428
+ *
429
+ * @returns [{tag, ac, tried, maxRating, minRating}] 按 AC 数降序
430
+ */
431
+ export function contactEvidence(db, uid) {
432
+ const rows = db.prepare(
433
+ `SELECT p.tags AS tags, s.verdict AS verdict, p.difficulty AS difficulty
434
+ FROM unified_submissions s
435
+ JOIN unified_problems p
436
+ ON p.platform = s.platform AND p.problem_id = s.problem_id
437
+ WHERE s.user_id = ?`).all(uid)
438
+ const byTag = new Map()
439
+ for (const r of rows) {
440
+ for (const t of String(r.tags ?? '').split(',').map((x) => x.trim()).filter(Boolean)) {
441
+ const e = byTag.get(t) ?? { tag: t, ac: 0, tried: 0, maxRating: 0, minRating: 0 }
442
+ e.tried++
443
+ if (r.verdict === 'AC') {
444
+ e.ac++
445
+ const d = r.difficulty ?? 0
446
+ if (d) {
447
+ if (!e.minRating || d < e.minRating) e.minRating = d
448
+ if (d > e.maxRating) e.maxRating = d
449
+ }
450
+ }
451
+ byTag.set(t, e)
452
+ }
453
+ }
454
+ return [...byTag.values()].sort((a, b) => b.ac - a.ac || b.tried - a.tried)
455
+ }