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,313 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""coach-metrics — 只报「真的能说明问题」的三个数(给小鲸用)
|
|
4
|
+
|
|
5
|
+
为什么只有这三个:教育研究里能证伪「当堂会了」的只有**延迟复测**和**没练过的新题**。
|
|
6
|
+
刷题数、在线时长那类叫 vanity metric —— 好看,但和水平只有弱相关,报它等于骗人。
|
|
7
|
+
|
|
8
|
+
1. 保持率 —— 同一节点,相邻两次检测间隔 >= 7 天时,后一次的通过率
|
|
9
|
+
2. 首通率 —— 按标签:第一次提交就 AC 的比例(他没练过的题才算数)
|
|
10
|
+
3. 卡住标记 —— 检测连续没过、或「在学」放太久还没验的节点
|
|
11
|
+
|
|
12
|
+
用法:
|
|
13
|
+
python ~/.dsh/scripts/coach-metrics.py # 人读
|
|
14
|
+
python ~/.dsh/scripts/coach-metrics.py --json # 机读
|
|
15
|
+
python ~/.dsh/scripts/coach-metrics.py --selftest # 自检
|
|
16
|
+
|
|
17
|
+
环境变量 COACH_DB / COACH_PROGRESS 可覆盖路径(与 acmer-coach 插件同一套约定)。
|
|
18
|
+
"""
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sqlite3
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import date, datetime
|
|
24
|
+
|
|
25
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
26
|
+
|
|
27
|
+
HOME = os.path.expanduser("~")
|
|
28
|
+
DB_PATH = os.environ.get("COACH_DB") or os.path.join(HOME, ".dsh", "data", "training.db")
|
|
29
|
+
PROGRESS_PATH = os.environ.get("COACH_PROGRESS") or os.path.join(
|
|
30
|
+
HOME, ".dsh", "knowledge", "PROGRESS.yaml")
|
|
31
|
+
|
|
32
|
+
# 训练库里的账号 handle。COACH_TRAINER 没给就去 users 里取第一条
|
|
33
|
+
# —— 单用户是常态,没有理由写死某个 handle。
|
|
34
|
+
TRAINER_HANDLES = tuple(
|
|
35
|
+
h for h in (os.environ.get("COACH_TRAINER"),) if h
|
|
36
|
+
)
|
|
37
|
+
RETENTION_GAP_DAYS = 7 # 「隔了 ≥7 天还做得出」才算保持
|
|
38
|
+
STUCK_CHECK_FAILS = 2 # 检测连续没过几次就标记
|
|
39
|
+
STUCK_IDLE_DAYS = 14 # 「在学」放多久还没验就标记
|
|
40
|
+
MIN_TAG_SAMPLE = 5 # 标签题量不足这个数就不报(小样本会骗人)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── 数据读取 ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
def load_progress(path=PROGRESS_PATH):
|
|
46
|
+
import yaml
|
|
47
|
+
with open(path, encoding="utf-8") as f:
|
|
48
|
+
return yaml.safe_load(f) or {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _as_date(v):
|
|
52
|
+
"""PROGRESS 里的日期可能是 date,也可能是字符串(老数据/手改)。"""
|
|
53
|
+
if isinstance(v, datetime):
|
|
54
|
+
return v.date()
|
|
55
|
+
if isinstance(v, date):
|
|
56
|
+
return v
|
|
57
|
+
try:
|
|
58
|
+
return date.fromisoformat(str(v)[:10])
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def trainer_id(db):
|
|
64
|
+
q = "SELECT id FROM users WHERE cf_handle = ? OR handle = ? LIMIT 1"
|
|
65
|
+
for h in TRAINER_HANDLES:
|
|
66
|
+
row = db.execute(q, (h, h)).fetchone()
|
|
67
|
+
if row:
|
|
68
|
+
return row[0]
|
|
69
|
+
row = db.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
|
|
70
|
+
return row[0] if row else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── 指标 ─────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def retention(progress):
|
|
76
|
+
"""保持率:同节点相邻两次检测间隔 >=7 天,取后一次的通过情况。
|
|
77
|
+
|
|
78
|
+
样本不足时**如实说不足** —— 不要用 0 对样本算出一个看着像样的百分比。
|
|
79
|
+
"""
|
|
80
|
+
pairs = []
|
|
81
|
+
for node, rec in (progress.get("nodes") or {}).items():
|
|
82
|
+
# 只拿**真做过**的检测配对。弃考(abandoned)不是一次测量 ——
|
|
83
|
+
# 把它算进分母,等于把"卷子作废"读成"他忘了",那是两种完全不同的信号。
|
|
84
|
+
checks = sorted(
|
|
85
|
+
(c for c in (rec or {}).get("checks") or []
|
|
86
|
+
if _as_date(c.get("date")) and c.get("outcome") in ("passed", "failed")),
|
|
87
|
+
key=lambda c: _as_date(c["date"]))
|
|
88
|
+
for prev, cur in zip(checks, checks[1:]):
|
|
89
|
+
gap = (_as_date(cur["date"]) - _as_date(prev["date"])).days
|
|
90
|
+
if gap >= RETENTION_GAP_DAYS:
|
|
91
|
+
pairs.append({"node": node, "gapDays": gap,
|
|
92
|
+
"from": str(_as_date(prev["date"])),
|
|
93
|
+
"on": str(_as_date(cur["date"])),
|
|
94
|
+
"outcome": cur.get("outcome")})
|
|
95
|
+
passed = sum(1 for p in pairs if p["outcome"] == "passed")
|
|
96
|
+
return {
|
|
97
|
+
"pairs": len(pairs),
|
|
98
|
+
"passed": passed,
|
|
99
|
+
"rate": (passed / len(pairs)) if pairs else None,
|
|
100
|
+
"detail": pairs[-5:],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def first_attempt_by_tag(db, uid):
|
|
105
|
+
"""首通率(按标签):每题**最早一次提交**的结果,AC 才算通。"""
|
|
106
|
+
rows = db.execute(
|
|
107
|
+
"""
|
|
108
|
+
SELECT s.platform, s.problem_id, s.verdict,
|
|
109
|
+
(SELECT verdict FROM unified_submissions x
|
|
110
|
+
WHERE x.user_id = s.user_id AND x.platform = s.platform
|
|
111
|
+
AND x.problem_id = s.problem_id
|
|
112
|
+
ORDER BY x.submitted_at ASC LIMIT 1) AS first_verdict,
|
|
113
|
+
p.tags
|
|
114
|
+
FROM unified_submissions s
|
|
115
|
+
LEFT JOIN unified_problems p
|
|
116
|
+
ON p.platform = s.platform AND p.problem_id = s.problem_id
|
|
117
|
+
WHERE s.user_id = ?
|
|
118
|
+
""", (uid,)).fetchall()
|
|
119
|
+
|
|
120
|
+
seen, per_tag = set(), {}
|
|
121
|
+
for platform, pid, _verdict, first_verdict, tags in rows:
|
|
122
|
+
key = (platform, pid)
|
|
123
|
+
if key in seen or first_verdict is None:
|
|
124
|
+
continue
|
|
125
|
+
seen.add(key)
|
|
126
|
+
for tag in (tags or "").split(","):
|
|
127
|
+
tag = tag.strip()
|
|
128
|
+
if not tag:
|
|
129
|
+
continue
|
|
130
|
+
bucket = per_tag.setdefault(tag, {"n": 0, "ac": 0})
|
|
131
|
+
bucket["n"] += 1
|
|
132
|
+
if first_verdict == "AC":
|
|
133
|
+
bucket["ac"] += 1
|
|
134
|
+
|
|
135
|
+
# 计数留着原样,别从取整后的比例反推 —— round 会吃掉一两道题
|
|
136
|
+
ranked = [{"tag": t, "n": v["n"], "ac": v["ac"], "cfa": v["ac"] / v["n"]}
|
|
137
|
+
for t, v in per_tag.items() if v["n"] >= MIN_TAG_SAMPLE]
|
|
138
|
+
ranked.sort(key=lambda r: (r["cfa"], -r["n"]))
|
|
139
|
+
total_n = sum(r["n"] for r in ranked)
|
|
140
|
+
total_ac = sum(r["ac"] for r in ranked)
|
|
141
|
+
return {
|
|
142
|
+
"problems": len(seen),
|
|
143
|
+
"overall": {"n": total_n, "cfa": (total_ac / total_n) if total_n else None},
|
|
144
|
+
"weakest": ranked[:5],
|
|
145
|
+
"strongest": ranked[-5:][::-1],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def stuck(progress, today=None):
|
|
150
|
+
"""卡住标记:**最近一次真做过的检测没过**,或「在学」放太久还没验。
|
|
151
|
+
|
|
152
|
+
⚠️ 三条别踩的坑(前两条是真踩过的):
|
|
153
|
+
1. **弃考(abandoned)不算没过** —— 那是卷子作废,信号在组卷那边,不在人身上。
|
|
154
|
+
2. **他后来过了就不算卡住** —— 二分那个节点就是:2 次没过 + 2 次作废,
|
|
155
|
+
但最后一次 4 分通过、状态已是 verified。只看"没过几次"会冤枉他。
|
|
156
|
+
3. 卡住是"最近一次没过",不是"历史上错过"。
|
|
157
|
+
"""
|
|
158
|
+
today = today or date.today()
|
|
159
|
+
out, cancelled = [], []
|
|
160
|
+
for node, rec in (progress.get("nodes") or {}).items():
|
|
161
|
+
rec = rec or {}
|
|
162
|
+
checks = rec.get("checks") or []
|
|
163
|
+
valid = [c for c in checks
|
|
164
|
+
if c.get("outcome") in ("passed", "failed") and _as_date(c.get("date"))]
|
|
165
|
+
valid.sort(key=lambda c: _as_date(c["date"]))
|
|
166
|
+
fails = [c for c in valid if c.get("outcome") == "failed"]
|
|
167
|
+
drops = [c for c in checks if c.get("outcome") == "abandoned"]
|
|
168
|
+
status = rec.get("status")
|
|
169
|
+
at = _as_date(rec.get("at"))
|
|
170
|
+
idle = (today - at).days if at else None
|
|
171
|
+
|
|
172
|
+
why = []
|
|
173
|
+
last = valid[-1] if valid else None
|
|
174
|
+
if last is not None and last.get("outcome") == "failed" and len(fails) >= STUCK_CHECK_FAILS:
|
|
175
|
+
why.append(f"最近一次检测没过(累计没过 {len(fails)} 次)")
|
|
176
|
+
if status == "studying" and idle is not None and idle >= STUCK_IDLE_DAYS:
|
|
177
|
+
why.append(f"在学放了 {idle} 天还没验")
|
|
178
|
+
if why:
|
|
179
|
+
out.append({"node": node, "status": status, "idleDays": idle,
|
|
180
|
+
"fails": len(fails), "why": ";".join(why),
|
|
181
|
+
"lastFail": str(_as_date(fails[-1]["date"])) if fails else None})
|
|
182
|
+
|
|
183
|
+
# 弃考是**组卷质量的信号**:卷子作废多了,该看题库/标签,不是看人。
|
|
184
|
+
if len(drops) >= STUCK_CHECK_FAILS:
|
|
185
|
+
cancelled.append({
|
|
186
|
+
"node": node, "drops": len(drops),
|
|
187
|
+
"reasons": [str(d.get("reason") or "").strip()[:60] for d in drops][-2:],
|
|
188
|
+
"last": str(_as_date(drops[-1]["date"])) if _as_date(drops[-1].get("date")) else None,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
out.sort(key=lambda r: (-r["fails"], -(r["idleDays"] or 0)))
|
|
192
|
+
cancelled.sort(key=lambda r: -r["drops"])
|
|
193
|
+
return {"stuck": out, "cancelled": cancelled}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def survey(progress):
|
|
197
|
+
counts = {}
|
|
198
|
+
for rec in (progress.get("nodes") or {}).values():
|
|
199
|
+
s = (rec or {}).get("status") or "未学"
|
|
200
|
+
counts[s] = counts.get(s, 0) + 1
|
|
201
|
+
return counts
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── 输出 ─────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def collect():
|
|
207
|
+
progress = load_progress()
|
|
208
|
+
db = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)
|
|
209
|
+
try:
|
|
210
|
+
uid = trainer_id(db)
|
|
211
|
+
if uid is None:
|
|
212
|
+
return {"error": "训练库里 users 表是空的,找不到账号"}
|
|
213
|
+
return {
|
|
214
|
+
"userId": uid,
|
|
215
|
+
"survey": survey(progress),
|
|
216
|
+
"retention": retention(progress),
|
|
217
|
+
"cfa": first_attempt_by_tag(db, uid),
|
|
218
|
+
"stuck": stuck(progress),
|
|
219
|
+
}
|
|
220
|
+
finally:
|
|
221
|
+
db.close()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def render(d):
|
|
225
|
+
if "error" in d:
|
|
226
|
+
return f"读取失败:{d['error']}"
|
|
227
|
+
L = []
|
|
228
|
+
s = d["survey"]
|
|
229
|
+
L.append("── 进度总览 " + " / ".join(f"{k} {v}" for k, v in sorted(s.items())))
|
|
230
|
+
r = d["retention"]
|
|
231
|
+
if r["rate"] is None:
|
|
232
|
+
L.append(f"── 保持率(隔 ≥{RETENTION_GAP_DAYS} 天还做得出):**样本不足**"
|
|
233
|
+
f"(现有 {r['pairs']} 对复测,不足 3 对就别下结论)")
|
|
234
|
+
else:
|
|
235
|
+
L.append(f"── 保持率(隔 ≥{RETENTION_GAP_DAYS} 天还做得出):"
|
|
236
|
+
f"{r['passed']}/{r['pairs']} = {r['rate']:.0%}")
|
|
237
|
+
c = d["cfa"]
|
|
238
|
+
if c["overall"]["cfa"] is not None:
|
|
239
|
+
L.append(f"── 首通率(他第一次提交就 AC):{c['overall']['cfa']:.0%}"
|
|
240
|
+
f"(覆盖 {c['overall']['n']} 题·标签对)")
|
|
241
|
+
if c["weakest"]:
|
|
242
|
+
L.append(" 最弱标签:" + "、".join(
|
|
243
|
+
f"{x['tag']} {x['cfa']:.0%}(n={x['n']})" for x in c["weakest"]))
|
|
244
|
+
L.append(" 最强标签:" + "、".join(
|
|
245
|
+
f"{x['tag']} {x['cfa']:.0%}(n={x['n']})" for x in c["strongest"]))
|
|
246
|
+
if d["stuck"]["stuck"]:
|
|
247
|
+
L.append("── ⚠️ 卡住标记(换法子,别继续喂题):")
|
|
248
|
+
for x in d["stuck"]["stuck"][:8]:
|
|
249
|
+
L.append(f" · {x['node']} —— {x['why']}(最近失败 {x['lastFail']})")
|
|
250
|
+
else:
|
|
251
|
+
L.append("── 卡住标记:无")
|
|
252
|
+
if d["stuck"]["cancelled"]:
|
|
253
|
+
L.append("── ⚠️ 卷子反复作废(**别算在他头上**,去看组卷):")
|
|
254
|
+
for x in d["stuck"]["cancelled"][:5]:
|
|
255
|
+
r = ";".join(s for s in x["reasons"] if s)
|
|
256
|
+
L.append(f" · {x['node']} —— 作废 {x['drops']} 次(最近 {x['last']})"
|
|
257
|
+
+ (f";原因:{r}" if r else ""))
|
|
258
|
+
return "\n".join(L)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def selftest():
|
|
262
|
+
"""最小自检:两个指标各喂一个手算得出的夹具,对不上就报错。"""
|
|
263
|
+
prog = {"nodes": {
|
|
264
|
+
"A": {"status": "verified", "checks": [
|
|
265
|
+
{"date": "2026-01-01", "outcome": "passed"},
|
|
266
|
+
{"date": "2026-01-12", "outcome": "passed"},
|
|
267
|
+
{"date": "2026-01-13", "outcome": "failed"}, # 间隔 1 天,不计入
|
|
268
|
+
]},
|
|
269
|
+
"B": {"status": "studying", "at": date(2026, 1, 1), "checks": [
|
|
270
|
+
{"date": "2026-02-01", "outcome": "failed"},
|
|
271
|
+
{"date": "2026-02-02", "outcome": "failed"},
|
|
272
|
+
]},
|
|
273
|
+
# C 是踩过的真形状:两次没过 + 两次作废,但**最后过了**。
|
|
274
|
+
# 只看"没过几次"会把这种判成卡住 —— 冤枉人。
|
|
275
|
+
"C": {"status": "verified", "checks": [
|
|
276
|
+
{"date": "2026-02-01", "outcome": "abandoned", "reason": "难度结构失衡"},
|
|
277
|
+
{"date": "2026-02-02", "outcome": "failed"},
|
|
278
|
+
{"date": "2026-02-03", "outcome": "failed"},
|
|
279
|
+
{"date": "2026-02-04", "outcome": "abandoned", "reason": "题已被 AC 过"},
|
|
280
|
+
{"date": "2026-02-05", "outcome": "passed"},
|
|
281
|
+
]},
|
|
282
|
+
}}
|
|
283
|
+
r = retention(prog)
|
|
284
|
+
assert r["pairs"] == 1 and r["passed"] == 1, f"保持率算错: {r}"
|
|
285
|
+
st = stuck(prog, today=date(2026, 3, 1))
|
|
286
|
+
assert {x["node"] for x in st["stuck"]} == {"B"}, f"卡住标记算错: {st['stuck']}"
|
|
287
|
+
assert [x["node"] for x in st["cancelled"]] == ["C"], f"作废标记算错: {st['cancelled']}"
|
|
288
|
+
# 首通:同一题提交两次,只看最早那次
|
|
289
|
+
db = sqlite3.connect(":memory:")
|
|
290
|
+
db.executescript("""
|
|
291
|
+
CREATE TABLE unified_submissions (user_id,platform,problem_id,verdict,submitted_at);
|
|
292
|
+
CREATE TABLE unified_problems (platform,problem_id,tags);
|
|
293
|
+
INSERT INTO unified_problems VALUES ('cf','1A','math'),('cf','1B','math'),
|
|
294
|
+
('cf','1C','math'),('cf','1D','math'),('cf','1E','math');
|
|
295
|
+
INSERT INTO unified_submissions VALUES
|
|
296
|
+
(1,'cf','1A','WA','2026-01-01'), (1,'cf','1A','AC','2026-01-02'),
|
|
297
|
+
(1,'cf','1B','AC','2026-01-01'), (1,'cf','1C','AC','2026-01-01'),
|
|
298
|
+
(1,'cf','1D','WA','2026-01-01'), (1,'cf','1E','AC','2026-01-01');
|
|
299
|
+
""")
|
|
300
|
+
c = first_attempt_by_tag(db, 1)
|
|
301
|
+
assert c["problems"] == 5, c
|
|
302
|
+
# 1A / 1D 的首交是 WA —— 后面补交的 AC 不算数,所以是 3/5 不是 4/5
|
|
303
|
+
assert abs(c["overall"]["cfa"] - 3 / 5) < 1e-9, c
|
|
304
|
+
print("selftest ok(保持率 / 卡住标记 / 首通率 三项都对得上)")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
if "--selftest" in sys.argv:
|
|
309
|
+
selftest()
|
|
310
|
+
elif "--json" in sys.argv:
|
|
311
|
+
print(json.dumps(collect(), ensure_ascii=False, indent=2, default=str))
|
|
312
|
+
else:
|
|
313
|
+
print(render(collect()))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""把旧版 langchain pickles 里的 OI Wiki 文本块导成 JSONL。
|
|
3
|
+
|
|
4
|
+
为什么要这一步(两个理由,都不是洁癖):
|
|
5
|
+
|
|
6
|
+
1. **安全。** pickle 反序列化 = 任意代码执行。原版查询脚本用的是
|
|
7
|
+
`allow_dangerous_deserialization=True` —— 自己机器上跑无所谓,但把
|
|
8
|
+
`.pkl` 打包发给陌生人,等于**送他一个 RCE 面**。JSONL 没有这个问题。
|
|
9
|
+
2. **可读。** JS 读不了 pickle。检索层要跑在 Node 里,格式必须能直接读。
|
|
10
|
+
|
|
11
|
+
顺便把 metadata 里的绝对路径换成**相对路径** —— 原索引里存的是构建机器上的
|
|
12
|
+
`~/.openclaw/skills/oi-wiki/OI-wiki/docs/...`,那个目录早就不在了,
|
|
13
|
+
换台机器全部失效。
|
|
14
|
+
|
|
15
|
+
用法: python export_oiwiki_chunks.py <index.pkl> <输出目录>
|
|
16
|
+
产物: <输出目录>/chunks.jsonl 每行 {"i":序号,"src":相对路径,"text":正文}
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import pickletools
|
|
21
|
+
import sys
|
|
22
|
+
import unicodedata
|
|
23
|
+
from io import BytesIO
|
|
24
|
+
|
|
25
|
+
# ── 白名单反序列化 ────────────────────────────────────────────────────
|
|
26
|
+
# 只放行 langchain 自己那两个类;别的一律抛。这是读**不可信 pickle** 的唯一
|
|
27
|
+
# 正确姿势:宁可读不了,也不要执行里面的东西。
|
|
28
|
+
ALLOWED = {
|
|
29
|
+
"langchain_community.docstore.in_memory",
|
|
30
|
+
"langchain_core.documents.base",
|
|
31
|
+
"langchain.docstore.document",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SafeUnpickler(__import__("pickle").Unpickler):
|
|
36
|
+
def find_class(self, module, name):
|
|
37
|
+
if module not in ALLOWED:
|
|
38
|
+
raise pickle_unsafe(f"pickle 里引用了白名单外的类:{module}.{name}")
|
|
39
|
+
return super().find_class(module, name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class pickle_unsafe(Exception):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def audit(path):
|
|
47
|
+
"""先用 pickletools 反汇编,确认只有已知的 GLOBAL —— 不解包就能判断。
|
|
48
|
+
(genops 产出 (opcode, arg, pos),arg 可能是 None,别多取一层。)"""
|
|
49
|
+
with open(path, "rb") as f:
|
|
50
|
+
ops = list(pickletools.genops(f.read()))
|
|
51
|
+
return [str(arg) for op, arg, _ in ops if op.name in ("GLOBAL", "STACK_GLOBAL")]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main():
|
|
55
|
+
if len(sys.argv) < 3:
|
|
56
|
+
print(__doc__)
|
|
57
|
+
return 1
|
|
58
|
+
pkl, outdir = sys.argv[1], sys.argv[2]
|
|
59
|
+
os.makedirs(outdir, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
print(f"· 反汇编审计 {os.path.basename(pkl)}")
|
|
62
|
+
for g in audit(pkl):
|
|
63
|
+
print(f" GLOBAL {g}")
|
|
64
|
+
|
|
65
|
+
with open(pkl, "rb") as f:
|
|
66
|
+
store = SafeUnpickler(f).load()
|
|
67
|
+
|
|
68
|
+
# langchain 的 FAISS save_local 落两个东西:
|
|
69
|
+
# index.pkl = (InMemoryDocstore, index_to_docstore_id)
|
|
70
|
+
docstore, id_map = store
|
|
71
|
+
docs = docstore._dict
|
|
72
|
+
n = len(id_map)
|
|
73
|
+
|
|
74
|
+
# 找所有 source 的公共前缀 —— 那才是"文档根",其余是相对路径
|
|
75
|
+
srcs = [d.metadata.get("source", "") for d in docs.values()]
|
|
76
|
+
root = os.path.commonpath([s for s in srcs if s]) if any(srcs) else ""
|
|
77
|
+
|
|
78
|
+
rows = []
|
|
79
|
+
for i in range(n):
|
|
80
|
+
doc = docs[id_map[i]]
|
|
81
|
+
src = doc.metadata.get("source", "")
|
|
82
|
+
rel = os.path.relpath(src, root).replace("\\", "/") if src and root else src
|
|
83
|
+
rows.append({"i": i, "src": rel, "text": doc.page_content})
|
|
84
|
+
|
|
85
|
+
dst = os.path.join(outdir, "chunks.jsonl")
|
|
86
|
+
with open(dst, "w", encoding="utf-8") as f:
|
|
87
|
+
for r in rows:
|
|
88
|
+
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
|
89
|
+
|
|
90
|
+
chars = sum(len(r["text"]) for r in rows)
|
|
91
|
+
files = len({r["src"] for r in rows})
|
|
92
|
+
print(f"· 导出 {len(rows)} 块 / {files} 个源文件 / {chars} 字符")
|
|
93
|
+
print(f" → {dst}")
|
|
94
|
+
print(f" 路径前缀(原构建机): {root}")
|
|
95
|
+
print(f" 样例: {rows[0]['src']}")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
sys.exit(main())
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""把题池导出成随包发的 JSONL.gz。
|
|
3
|
+
|
|
4
|
+
**为什么要随包发**:`coach_pool` 挑候选时要满足「他没做过」这个条件。
|
|
5
|
+
没有题池的话,候选只能从他做过的那几道里挑 —— 那个条件永远满足不了,
|
|
6
|
+
教练就废了一半。
|
|
7
|
+
|
|
8
|
+
**为什么是 gzip**:41k 道题的标签/标题是高度重复的文本,gzip 压到 1/5。
|
|
9
|
+
不压的话包里多 4.8MB 纯文本。
|
|
10
|
+
|
|
11
|
+
⚠️ **数据来源与风险**(见 assets/pool/LICENSE):
|
|
12
|
+
· codeforces —— 官方公开 API(`problemset.problems`),可随时重新拉
|
|
13
|
+
· nowcoder —— `ac.nowcoder.com/acm/problem/list/json`,公开接口但**非官方承诺**
|
|
14
|
+
· luogu —— **爬取的**。洛谷的 ToS 不允许抓取,且已有账号因此被封的先例。
|
|
15
|
+
这一部分的再分发**有风险**,用不用由使用者自己判断。
|
|
16
|
+
|
|
17
|
+
用法:
|
|
18
|
+
python export_problem_pool.py <training.db> <输出目录>
|
|
19
|
+
产物: <输出目录>/problems.jsonl.gz
|
|
20
|
+
"""
|
|
21
|
+
import gzip
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sqlite3
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
if len(sys.argv) < 3:
|
|
30
|
+
print(__doc__)
|
|
31
|
+
return 1
|
|
32
|
+
db_path, outdir = sys.argv[1], sys.argv[2]
|
|
33
|
+
os.makedirs(outdir, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
36
|
+
rows = con.execute(
|
|
37
|
+
"""SELECT platform, problem_id, title, difficulty, tags, url
|
|
38
|
+
FROM unified_problems
|
|
39
|
+
WHERE is_library = 1 AND problem_id IS NOT NULL AND problem_id != ''
|
|
40
|
+
ORDER BY platform, problem_id""").fetchall()
|
|
41
|
+
con.close()
|
|
42
|
+
|
|
43
|
+
dst = os.path.join(outdir, "problems.jsonl.gz")
|
|
44
|
+
n = 0
|
|
45
|
+
with gzip.open(dst, "wt", encoding="utf-8", compresslevel=9) as f:
|
|
46
|
+
for platform, pid, title, diff, tags, url in rows:
|
|
47
|
+
f.write(json.dumps({
|
|
48
|
+
"p": platform, "i": pid, "t": title or "",
|
|
49
|
+
"d": float(diff or 0), "g": tags or "", "u": url or "",
|
|
50
|
+
}, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
51
|
+
n += 1
|
|
52
|
+
|
|
53
|
+
size = os.path.getsize(dst) / 1024 / 1024
|
|
54
|
+
by = {}
|
|
55
|
+
for r in rows:
|
|
56
|
+
by[r[0]] = by.get(r[0], 0) + 1
|
|
57
|
+
print(f"· 导出 {n} 道 → {dst} ({size:.2f} MB)")
|
|
58
|
+
for k, v in sorted(by.items()):
|
|
59
|
+
print(f" {k:12} {v}")
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
sys.exit(main())
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
extract_oiwiki.py — 从 OI-wiki 的 mkdocs.yml 导航提取完整算法树
|
|
4
|
+
|
|
5
|
+
为什么要它有:地图的「完整性」不能靠我一个人想 —— OI-wiki 是社区维护的
|
|
6
|
+
全量索引,拿它当兜底,我漏掉的东西(插头DP / 李超线段树 / Dancing Links …)
|
|
7
|
+
就漏不掉。
|
|
8
|
+
|
|
9
|
+
输出:JSON,结构 = [ {domain, path[], name, file} ],path 是从学科到该条目的层级,
|
|
10
|
+
file 是该条目对应的 markdown 文件(相对 docs/,导航里没挂文件的为 null)。
|
|
11
|
+
|
|
12
|
+
⚠️ **file 是重点**:地图节点名和 OI-wiki 的导航标题是同一套词,所以
|
|
13
|
+
`name → file` 就是一张**精确的「知识点 → 页面」表** —— 查资料不用靠模糊搜索去猜,
|
|
14
|
+
直接查这张表。丢了它就只能对着一堆英文文件名(`seg.md`)猜哪个是线段树。
|
|
15
|
+
|
|
16
|
+
只取算法相关的学科,跳过「简介/工具软件/语言基础/比赛相关」。
|
|
17
|
+
|
|
18
|
+
用法: python extract_oiwiki.py [输出 json] [mkdocs.yml 路径]
|
|
19
|
+
不给路径就用 $OI_WIKI_DIR/OI-wiki/mkdocs.yml(默认 ~/.dsh/skills/oi-wiki)
|
|
20
|
+
"""
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
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
|
+
_DEFAULT_WIKI_DIR = os.environ.get("OI_WIKI_DIR") or os.path.join(
|
|
30
|
+
os.path.expanduser("~"), ".dsh", "skills", "oi-wiki")
|
|
31
|
+
WIKI = sys.argv[2] if len(sys.argv) > 2 else os.path.join(
|
|
32
|
+
_DEFAULT_WIKI_DIR, "OI-wiki", "mkdocs.yml")
|
|
33
|
+
OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
|
|
34
|
+
os.path.dirname(os.path.abspath(__file__)), "oiwiki_tree.json")
|
|
35
|
+
|
|
36
|
+
# 取这些学科(算法相关);其余是工具/语言/赛事,不进地图
|
|
37
|
+
ALGO = ["算法基础", "搜索", "动态规划", "字符串", "数学",
|
|
38
|
+
"数据结构", "图论", "计算几何", "杂项", "专题"]
|
|
39
|
+
# 章节导言页不是知识点
|
|
40
|
+
SKIP_SUFFIX = ("部分简介", "简介")
|
|
41
|
+
# 这些不是"知识点"而是写法/元信息
|
|
42
|
+
SKIP_EXACT = {"学习路线", "学习资源", "常见错误", "常见技巧", "读入、输出优化",
|
|
43
|
+
"分段打表", "题型概述", "交互题", "出题", "技巧"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_nav(path):
|
|
47
|
+
lines = open(path, encoding="utf-8").read().splitlines()
|
|
48
|
+
start = next(i for i, l in enumerate(lines) if l.startswith("nav:"))
|
|
49
|
+
end = start + 1
|
|
50
|
+
while end < len(lines) and (lines[end].startswith(" ") or not lines[end].strip()):
|
|
51
|
+
end += 1
|
|
52
|
+
tree, stack = [], []
|
|
53
|
+
for l in lines[start + 1:end]:
|
|
54
|
+
if not l.strip():
|
|
55
|
+
continue
|
|
56
|
+
ind = len(l) - len(l.lstrip())
|
|
57
|
+
s = l.strip()
|
|
58
|
+
if not s.startswith("- "):
|
|
59
|
+
continue
|
|
60
|
+
s = s[2:]
|
|
61
|
+
# 格式是 `- 标题: 路径.md`;只有标题的是分类节点(没有自己的页面)。
|
|
62
|
+
# **路径要留着** —— 它就是「知识点 → 文件」的映射,见文件头。
|
|
63
|
+
name, _, file = s.partition(":")
|
|
64
|
+
name = name.strip()
|
|
65
|
+
file = file.strip() or None
|
|
66
|
+
while stack and stack[-1][0] >= ind:
|
|
67
|
+
stack.pop()
|
|
68
|
+
node = {"name": name, "file": file, "kids": []}
|
|
69
|
+
(stack[-1][1]["kids"] if stack else tree).append(node)
|
|
70
|
+
stack.append((ind, node))
|
|
71
|
+
return tree
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def flatten(tree):
|
|
75
|
+
out = []
|
|
76
|
+
for domain in tree:
|
|
77
|
+
if domain["name"] not in ALGO:
|
|
78
|
+
continue
|
|
79
|
+
for l2 in domain["kids"]:
|
|
80
|
+
n2 = l2["name"]
|
|
81
|
+
if n2 not in SKIP_EXACT and not n2.endswith(SKIP_SUFFIX):
|
|
82
|
+
out.append({"domain": domain["name"], "path": [n2],
|
|
83
|
+
"name": n2, "file": l2.get("file")})
|
|
84
|
+
for l3 in l2["kids"]:
|
|
85
|
+
n3 = l3["name"]
|
|
86
|
+
if n3 in SKIP_EXACT or n3.endswith(SKIP_SUFFIX):
|
|
87
|
+
continue
|
|
88
|
+
out.append({"domain": domain["name"], "path": [n2, n3],
|
|
89
|
+
"name": n3, "file": l3.get("file")})
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main():
|
|
94
|
+
tree = parse_nav(WIKI)
|
|
95
|
+
items = flatten(tree)
|
|
96
|
+
with open(OUT, "w", encoding="utf-8") as f:
|
|
97
|
+
json.dump(items, f, ensure_ascii=False, indent=1)
|
|
98
|
+
|
|
99
|
+
from collections import Counter
|
|
100
|
+
by_domain = Counter(i["domain"] for i in items)
|
|
101
|
+
by_depth = Counter(len(i["path"]) for i in items)
|
|
102
|
+
print(f"提取完成 → {OUT}")
|
|
103
|
+
print(f" 条目总数 {len(items)}")
|
|
104
|
+
print(f" 按学科: " + " ".join(f"{k}={v}" for k, v in by_domain.most_common()))
|
|
105
|
+
print(f" 按层级: " + " ".join(f"{k}级={v}" for k, v in sorted(by_depth.items())))
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
sys.exit(main())
|