acmer-coach 2.1.0 → 2.2.1
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/README.md +12 -2
- package/assets/knowledge/vp.html +47 -0
- package/assets/rules/coach-rules.md +4 -2
- package/dev/vp-client-check.mjs +46 -0
- package/dev.sh +1 -1
- package/index.js +187 -12
- package/lib/client.js +38 -0
- package/lib/vp-client.js +148 -0
- package/package.json +1 -1
- package/verify-shape.mjs +3 -0
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
## 它到底做了什么
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
29 个工具,三条轴:知识图、比赛能力、训练证据。
|
|
24
24
|
|
|
25
25
|
| 轴 | 含义 |
|
|
26
26
|
|---|---|
|
|
@@ -100,7 +100,17 @@ learned/verified 记录,或路线中在此前安排的必验训练,不能从
|
|
|
100
100
|
`coach_plan` 继续管理近期时间块。旧 v1 路线可读,会标记 `needsReview`;需明确
|
|
101
101
|
修订当前及后续阶段后迁移 v2,插件不会自动重写。优先级与教学方案仍由模型判断,
|
|
102
102
|
程序现在会要求目标和 VP 证据先进入策略层;能力卡片仍由仓库维护,未知卡片不会自动臆造。
|
|
103
|
-
|
|
103
|
+
现在可以直接连接 VP:`coach_vp_connect start` 打开授权页,登录并允许只读访问后,
|
|
104
|
+
把一次性 code 交给 `coach_vp_connect finish`。然后用 `coach_vp_sync` 增量同步比赛、
|
|
105
|
+
提交、事件和个人历史;同步会自动生成带账号前缀的 VP 训练证据,仍由复盘和检测决定
|
|
106
|
+
能力结论。`coach_vp_replay` 读取指定比赛的细粒度回放,`VP 回放`按钮会打开可视化页面,
|
|
107
|
+
把 AC、WA/TLE/RE 和比赛分钟画在同一条时间轴上。
|
|
108
|
+
|
|
109
|
+
每个 VP 账号都放在 `~/.dsh/knowledge/vp-accounts/<user_id>/`,token 单独保存,
|
|
110
|
+
同步游标、原始安全投影和回放缓存都按账号分开。切换账号会切换教练工作区;服务端
|
|
111
|
+
返回的账号 id 与当前账号不一致时,客户端拒绝写入。同步和回放都不返回提交代码或 judge 输出。
|
|
112
|
+
`disconnect` 只删除本地 token,保留缓存,方便重新连接同一个账号。
|
|
113
|
+
|
|
104
114
|
详见 [长期课程机制](docs/长期课程机制.md)。
|
|
105
115
|
|
|
106
116
|
**「学过」和「已验证」是两级,绝不合并。** 自评 ≠ 掌握 ——
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>VP 回放 · 小鲸教练</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: dark; --bg:#0d1117; --panel:#161b22; --line:#30363d; --text:#e6edf3; --muted:#8b949e; --blue:#58a6ff; --green:#3fb950; --red:#f85149; --yellow:#d29922; }
|
|
9
|
+
* { box-sizing:border-box } body { margin:0; padding:24px; color:var(--text); background:radial-gradient(circle at 85% 0,#1b3150 0,#0d1117 45%); font:14px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif; }
|
|
10
|
+
.wrap { max-width:1180px; margin:auto } h1 { margin:0 0 4px; font-size:25px } h2 { font-size:16px; margin:0 0 12px } .muted { color:var(--muted) }
|
|
11
|
+
.grid { display:grid; grid-template-columns:320px 1fr; gap:16px; margin-top:18px } .card { background:color-mix(in srgb,var(--panel) 92%,transparent); border:1px solid var(--line); border-radius:12px; padding:16px; box-shadow:0 10px 35px #0003; }
|
|
12
|
+
button,input { border:1px solid var(--line); border-radius:7px; padding:8px 10px; color:var(--text); background:#21262d; font:inherit } button { cursor:pointer; background:#1f6feb; border-color:#388bfd } button.secondary { background:#21262d } button:hover { filter:brightness(1.15) } input { width:100%; margin:5px 0 9px; background:#0d1117 }
|
|
13
|
+
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap } .row button { flex:1 } .status { padding:10px 12px; border-radius:8px; background:#0d1117; margin:10px 0 14px; color:var(--muted) } .status.ok { color:#aff5b4; border:1px solid #238636 }
|
|
14
|
+
.stats { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin:12px 0 } .stat { padding:10px; border:1px solid var(--line); border-radius:8px; background:#0d1117 } .stat b { display:block; font-size:20px; color:var(--blue) }
|
|
15
|
+
#timeline { width:100%; min-height:220px; overflow:auto; border:1px solid var(--line); border-radius:8px; background:#0d1117; padding:12px } svg { width:100%; min-width:620px; height:190px } .legend { display:flex; gap:12px; color:var(--muted); font-size:12px; margin-top:6px } .dot { display:inline-block; width:9px; height:9px; border-radius:50%; margin-right:4px }
|
|
16
|
+
.problem { display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid #21262d } .pill { border-radius:999px; padding:2px 8px; font-size:12px; background:#30363d } .pill.AC { color:#aff5b4; background:#173b25 } .pill.WA,.pill.TLE,.pill.RE { color:#ffb4ab; background:#4b1d20 }
|
|
17
|
+
#message { min-height:24px; margin-top:12px; color:var(--muted) } a { color:var(--blue) }
|
|
18
|
+
@media (max-width:760px) { body{padding:14px} .grid{grid-template-columns:1fr} }
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body><main class="wrap">
|
|
22
|
+
<h1>VP 回放</h1><div class="muted">账号数据按 VP user id 隔离;这里的时间轴是比赛墙上秒数。</div>
|
|
23
|
+
<div class="grid">
|
|
24
|
+
<section class="card"><h2>连接 VP</h2><div id="account" class="status">正在读取连接状态…</div>
|
|
25
|
+
<button id="start">打开 VP 登录授权</button>
|
|
26
|
+
<label class="muted">授权页显示的 code</label><input id="code" autocomplete="off" placeholder="粘贴一次性 code">
|
|
27
|
+
<button id="finish" class="secondary">完成连接</button><div class="row" style="margin-top:8px"><button id="sync" class="secondary">立即同步</button><button id="disconnect" class="secondary">断开账号</button></div>
|
|
28
|
+
<div id="authLink" class="muted"></div><div id="message"></div>
|
|
29
|
+
</section>
|
|
30
|
+
<section class="card"><h2>比赛回放</h2><div class="row"><input id="contest" type="number" min="1" placeholder="Contest ID"><button id="load">加载回放</button></div>
|
|
31
|
+
<div id="summary" class="muted" style="margin:12px 0">先连接 VP,再输入 Contest ID。</div><div id="timeline"></div><div id="problems"></div>
|
|
32
|
+
</section>
|
|
33
|
+
</div>
|
|
34
|
+
</main>
|
|
35
|
+
<script>
|
|
36
|
+
const $ = (id) => document.getElementById(id)
|
|
37
|
+
const say = (text, bad=false) => { $('message').textContent = text; $('message').style.color = bad ? '#f85149' : '' }
|
|
38
|
+
async function api(path, options) { const r=await fetch(path, {headers:{'content-type':'application/json'}, ...options}); const d=await r.json(); if(!r.ok||d.ok===false) throw new Error(d.reject||`HTTP ${r.status}`); return d }
|
|
39
|
+
async function status(){ try { const d=await api('./api/vp/status'); const a=d.account; $('account').textContent=d.connected?`${a.display_name||a.handle} · ${a.handle} · 账号 ${a.id}`:'未连接 VP'; $('account').className='status'+(d.connected?' ok':''); } catch(e){say(e.message,true)} }
|
|
40
|
+
$('start').onclick=async()=>{try{const d=await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'start'})}); $('authLink').innerHTML=`<a href="${d.authorizeUrl}" target="_blank" rel="noreferrer">打开授权页</a>(登录并允许读取)`; say('授权页已打开,完成后把 code 粘贴到上面。'); window.open(d.authorizeUrl,'_blank','noopener');}catch(e){say(e.message,true)}}
|
|
41
|
+
$('finish').onclick=async()=>{try{await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'finish',code:$('code').value})}); say('连接成功,工作区已切换。'); await status()}catch(e){say(e.message,true)}}
|
|
42
|
+
$('sync').onclick=async()=>{try{const d=await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'sync'})}); say(`同步完成:${d.submissions?.length||0} 条提交。`); await status()}catch(e){say(e.message,true)}}
|
|
43
|
+
$('disconnect').onclick=async()=>{try{await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'disconnect'})}); say('已断开,缓存保留。'); await status()}catch(e){say(e.message,true)}}
|
|
44
|
+
$('load').onclick=async()=>{try{const d=await api(`./api/vp/replay?contestId=${encodeURIComponent($('contest').value)}`); render(d); say('回放加载完成。')}catch(e){say(e.message,true)}}
|
|
45
|
+
function render(d){ const v=d.visualization||{}, ev=v.team_events||[], duration=Math.max(60,v.duration_seconds||1), w=900, pad=42; $('summary').innerHTML=`<b>${d.contest?.title||'Contest '+d.contest?.id}</b><div class="stats"><div class="stat"><b>${d.result?.own?.solved??d.result?.solved??0}</b> solved</div><div class="stat"><b>${ev.length}</b> team events</div><div class="stat"><b>${v.official_run_count||0}</b> official runs</div></div>`; let ticks=Array.from({length:6},(_,i)=>{const x=pad+(w-pad*2)*i/5;return `<line x1="${x}" y1="28" x2="${x}" y2="160" stroke="#21262d"/><text x="${x}" y="178" fill="#8b949e" text-anchor="middle">${Math.round(duration*i/5/60)}m</text>`}).join(''); let marks=ev.map(e=>{const x=pad+(w-pad*2)*Math.min(1,Math.max(0,(e.second||0)/duration));const good=String(e.verdict||'').toUpperCase()==='AC';const color=good?'#3fb950':'#f85149';return `<circle cx="${x}" cy="${good?70:112}" r="7" fill="${color}"/><title>${e.problem||''} · ${e.verdict||''} · ${Math.round((e.second||0)/60)}m</title>`}).join(''); $('timeline').innerHTML=`<svg viewBox="0 0 ${w} 190" role="img" aria-label="比赛提交时间轴"><text x="8" y="74" fill="#8b949e">AC</text><text x="8" y="116" fill="#8b949e">非 AC</text>${ticks}<line x1="${pad}" y1="70" x2="${w-pad}" y2="70" stroke="#3fb950"/><line x1="${pad}" y1="112" x2="${w-pad}" y2="112" stroke="#f85149"/>${marks}</svg><div class="legend"><span><i class="dot" style="background:#3fb950"></i>AC</span><span><i class="dot" style="background:#f85149"></i>WA/TLE/RE</span><span>横轴为比赛分钟</span></div>`; $('problems').innerHTML=(d.problems||[]).map(p=>`<div class="problem"><span>${p.label||p.id} ${p.title||''}</span><span class="pill">${p.id}</span></div>`).join('') }
|
|
46
|
+
status()
|
|
47
|
+
</script></body></html>
|
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
和能力优先级。
|
|
15
15
|
* `coach_focus` 给的是比赛能力重点,不是知识点清单。每次训练必须说明它服务哪个能力、
|
|
16
16
|
为什么现在排、暂缓什么,以及用什么表现退出。知识图只负责把能力重点展开成可学节点。
|
|
17
|
-
* 有 VP
|
|
18
|
-
|
|
17
|
+
* 有 VP 时先用 `coach_vp_connect status` 检查账号,未连接就走 `start → 登录授权页 → finish`,
|
|
18
|
+
再用 `coach_vp_sync` 拉取真实记录;赛后调 `coach_vp_replay` 看秒级时间轴,最后调
|
|
19
|
+
`coach_postmortem`。VP 的读题、耗时、提交、换题和团队问题优先于 rating;没有 VP 证据时要
|
|
20
|
+
明确说“尚无记录”,不能装作知道短板。手工导入仍可用 `coach_vp_import`,但不能和当前账号记录混用。
|
|
19
21
|
* 讲知识点之前调 `coach_scope`。必须说明适用范围、延伸、反例、常见错误、迁移题和退出
|
|
20
22
|
条件;工具返回“没有范围卡片”时不许凭记忆补一段看似完整的范围。
|
|
21
23
|
* 训练重点是比赛能力,不是沿着地图无限往下走。阶段达到目标后可以停在当前节点,转做
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { createVpClient } from '../lib/vp-client.js'
|
|
6
|
+
|
|
7
|
+
const root = mkdtempSync(join(tmpdir(), 'acmer-coach-vp-'))
|
|
8
|
+
let account = { id: 1, handle: 'A', display_name: 'Coach A' }
|
|
9
|
+
let mismatch = false
|
|
10
|
+
const fetchImpl = async (url, options = {}) => {
|
|
11
|
+
const path = new URL(url).pathname
|
|
12
|
+
const body = options.body ? JSON.parse(options.body) : {}
|
|
13
|
+
if (path.endsWith('/api/coach/auth/start')) return json({ request_id: 'r1', authorize_url: 'https://vp/authorize/r1' })
|
|
14
|
+
if (path.endsWith('/api/coach/auth/exchange')) {
|
|
15
|
+
account = body.code === 'B' ? { id: 2, handle: 'B', display_name: 'Coach B' } : { id: 1, handle: 'A', display_name: 'Coach A' }
|
|
16
|
+
return json({ access_token: `token-${account.id}`, user: account })
|
|
17
|
+
}
|
|
18
|
+
if (path.endsWith('/api/coach/me')) return json({ user: account, read_only: true })
|
|
19
|
+
if (path.endsWith('/api/coach/sync')) return json({
|
|
20
|
+
account: mismatch ? { id: 1, handle: 'A' } : account,
|
|
21
|
+
submissions: [{ id: 9, contest_id: 7, verdict: 'AC' }], events: [], contests: [],
|
|
22
|
+
next_cursor: { submission_id: 9, event_id: 0 }, has_more: false,
|
|
23
|
+
})
|
|
24
|
+
if (path.endsWith('/api/coach/replay/7')) return json({ account, visualization: { team_events: [] } })
|
|
25
|
+
throw new Error(`unexpected ${url}`)
|
|
26
|
+
}
|
|
27
|
+
function json(value) { return { ok: true, json: async () => value, text: async () => JSON.stringify(value) } }
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const client = createVpClient({ dataDir: root, fetchImpl })
|
|
31
|
+
const start = await client.start({ baseUrl: 'https://vp/' })
|
|
32
|
+
assert.equal(start.requestId, 'r1')
|
|
33
|
+
await client.finish({ code: 'A' })
|
|
34
|
+
const synced = await client.sync()
|
|
35
|
+
assert.equal(synced.account.id, 1)
|
|
36
|
+
assert.equal(JSON.parse(readFileSync(join(root, 'vp-accounts', '1', 'SYNC.json'))).account.id, 1)
|
|
37
|
+
await client.finish({ code: 'B' })
|
|
38
|
+
assert.equal((await client.status()).account.id, 2)
|
|
39
|
+
assert.equal((await client.sync()).account.id, 2)
|
|
40
|
+
mismatch = true
|
|
41
|
+
await assert.rejects(() => client.sync(), /account mismatch/)
|
|
42
|
+
assert.equal(JSON.parse(readFileSync(join(root, 'vp-accounts', '2', 'SYNC.json'))).account.id, 2)
|
|
43
|
+
console.log('VP CLIENT ALL PASSED')
|
|
44
|
+
} finally {
|
|
45
|
+
rmSync(root, { recursive: true, force: true })
|
|
46
|
+
}
|
package/dev.sh
CHANGED
|
@@ -35,7 +35,7 @@ done
|
|
|
35
35
|
# 从副本目录跑 `bash dev.sh` 走的是另一条路(`dsh plugin remove/add`,而那条
|
|
36
36
|
# 路不可靠),和这份的行为不一样。换句话说,**同一个命令,从哪个目录按回车
|
|
37
37
|
# 决定跑哪份脚本** —— 两份 dev.sh 就是两份真相,正是这份文件在防的那种坑。
|
|
38
|
-
SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs docs/长期课程机制.md dev.sh'
|
|
38
|
+
SYNC_FILES='index.js package.json verify.mjs verify-shape.mjs progress-cli.mjs build-node-meta.mjs cordis.patch.yml README.md lib/client.js lib/vp-client.js lib/setup.js lib/curriculum.js lib/curriculum-evidence.js lib/curriculum-planning.js lib/strategy.js assets/knowledge/COMPETENCIES.yaml assets/knowledge/vp.html assets/rules/coach-rules.md dev/curriculum-check.mjs dev/curriculum-evidence-check.mjs dev/strategy-check.mjs dev/vp-client-check.mjs docs/长期课程机制.md dev.sh'
|
|
39
39
|
STALE=''
|
|
40
40
|
for f in $SYNC_FILES; do
|
|
41
41
|
for d in "${DSTS[@]}"; do
|
package/index.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// · `file:` 依赖装出来的是**硬链接**,不是拷贝。原地写两边同变,但改名式
|
|
24
24
|
// 写入(Edit 工具、VS Code 原子保存)会**断开链接**,断了两边分家,
|
|
25
25
|
// 而 `dsh plugin add` 认不出分家、不重链也不报错 —— 所以改完一律跑 dev.sh。
|
|
26
|
-
import { copyFileSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync } from 'node:fs'
|
|
26
|
+
import { copyFileSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync } from 'node:fs'
|
|
27
27
|
import { join } from 'node:path'
|
|
28
28
|
import { homedir } from 'node:os'
|
|
29
29
|
import { parse, stringify } from 'yaml'
|
|
@@ -31,11 +31,12 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
31
31
|
import { initDataDir, syncCodeforces, contactEvidence, importPool, importRecords } from './lib/setup.js'
|
|
32
32
|
import { createCurriculumStore, curriculumTool } from './lib/curriculum.js'
|
|
33
33
|
import { createStrategyStore } from './lib/strategy.js'
|
|
34
|
+
import { createVpClient } from './lib/vp-client.js'
|
|
34
35
|
|
|
35
36
|
const name = 'acmer-coach'
|
|
36
37
|
const inject = ['tools']
|
|
37
38
|
|
|
38
|
-
const VERSION = '2.
|
|
39
|
+
const VERSION = '2.2.0'
|
|
39
40
|
|
|
40
41
|
// 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
|
|
41
42
|
// 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
|
|
@@ -71,14 +72,30 @@ const TEACH_FLOOR_MIN = 10
|
|
|
71
72
|
const DATA_DIR_EXPLICIT = Boolean(process.env.COACH_DATA_DIR)
|
|
72
73
|
const DATA_DIR = process.env.COACH_DATA_DIR || join(homedir(), '.dsh', 'knowledge')
|
|
73
74
|
const MAP_PATH = () => join(DATA_DIR, 'MAP.yaml')
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
75
|
+
let ACTIVE_DATA_DIR = DATA_DIR
|
|
76
|
+
let strategy
|
|
77
|
+
let curriculum
|
|
78
|
+
function createStores(dataDir) {
|
|
79
|
+
strategy = createStrategyStore({
|
|
80
|
+
dataDir, competenciesPath: join(import.meta.dirname, 'assets', 'knowledge', 'COMPETENCIES.yaml'),
|
|
81
|
+
})
|
|
82
|
+
curriculum = createCurriculumStore({
|
|
83
|
+
dataDir, loadMap, loadProgress, loadSchedule,
|
|
84
|
+
loadStrategyEvidence: () => strategy.evidence().evidence,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
createStores(ACTIVE_DATA_DIR)
|
|
88
|
+
|
|
89
|
+
function switchCoachWorkspace(accountId = '') {
|
|
90
|
+
ACTIVE_DATA_DIR = accountId ? join(DATA_DIR, 'vp-accounts', String(accountId)) : DATA_DIR
|
|
91
|
+
mkdirSync(ACTIVE_DATA_DIR, { recursive: true })
|
|
92
|
+
createStores(ACTIVE_DATA_DIR)
|
|
93
|
+
return ACTIVE_DATA_DIR
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const vpClient = createVpClient({ dataDir: DATA_DIR })
|
|
97
|
+
const savedVp = vpClient.localStatus()
|
|
98
|
+
if (savedVp.connected) switchCoachWorkspace(savedVp.activeAccountId)
|
|
82
99
|
|
|
83
100
|
// 探针要的文件,各自代表一条后面的依赖:
|
|
84
101
|
// MAP.yaml 地图(coach_next 读它算候选)
|
|
@@ -170,7 +187,7 @@ function loadMap() {
|
|
|
170
187
|
//
|
|
171
188
|
// 游标显式存,不靠"已验证的最大深度"推 —— 推出来的东西没法手改,
|
|
172
189
|
// 而且他想复习某节时游标要能停在那儿。
|
|
173
|
-
const PROGRESS_PATH = () => join(
|
|
190
|
+
const PROGRESS_PATH = () => join(ACTIVE_DATA_DIR, 'PROGRESS.yaml')
|
|
174
191
|
|
|
175
192
|
function emptyProgress() {
|
|
176
193
|
return { version: 1, cursor: '', updated: '', nodes: {}, pending: null }
|
|
@@ -309,7 +326,7 @@ function saveProgress(p) {
|
|
|
309
326
|
//
|
|
310
327
|
// `actual` 是**校准真实做题速度**的唯一来源 —— 计划 60 分钟实际 95 分钟,
|
|
311
328
|
// 攒几周才知道他的速度到底是多少。这条现在一条数据都没有,所以排程只能保守。
|
|
312
|
-
const SCHEDULE_PATH = () => join(
|
|
329
|
+
const SCHEDULE_PATH = () => join(ACTIVE_DATA_DIR, 'SCHEDULE.yaml')
|
|
313
330
|
|
|
314
331
|
function emptySchedule() {
|
|
315
332
|
return { version: 1, updated: '', days: {} }
|
|
@@ -1410,6 +1427,7 @@ const reachableOf = (entry, rating) =>
|
|
|
1410
1427
|
!entry || !rating || Number(entry) <= rating + REACH_BUFFER
|
|
1411
1428
|
|
|
1412
1429
|
const SKILLTREE_HTML = () => join(DATA_DIR, 'skilltree.html')
|
|
1430
|
+
const VP_HTML = () => join(import.meta.dirname, 'assets', 'knowledge', 'vp.html')
|
|
1413
1431
|
|
|
1414
1432
|
// ── /coach 路由:把技能树挂在 dsh 自己的 web 服务上 ──────────────────
|
|
1415
1433
|
//
|
|
@@ -1492,6 +1510,38 @@ function registerPages(ctx) {
|
|
|
1492
1510
|
return
|
|
1493
1511
|
}
|
|
1494
1512
|
|
|
1513
|
+
if (pathname === '/coach/vp.html') {
|
|
1514
|
+
try { return send(res, 200, 'text/html; charset=utf-8', readFileSync(VP_HTML())) }
|
|
1515
|
+
catch (err) { return send(res, 404, 'text/plain; charset=utf-8', `VP 页面缺失(${err?.code ?? err?.message})`) }
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
if (pathname === '/coach/api/vp/status' && req.method === 'GET') {
|
|
1519
|
+
return sendJson(res, 200, { ok: true, ...vpClient.localStatus() })
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
if (pathname === '/coach/api/vp/replay' && req.method === 'GET') {
|
|
1523
|
+
const id = new URL(req.url ?? '/', 'http://dsh.internal').searchParams.get('contestId')
|
|
1524
|
+
if (!id) return sendJson(res, 400, { ok: false, reject: '缺少 contestId' })
|
|
1525
|
+
try { return sendJson(res, 200, { ok: true, ...(await vpClient.replay({ contestId: id })) }) }
|
|
1526
|
+
catch (err) { return sendJson(res, 400, { ok: false, reject: String(err?.message || err) }) }
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
if (pathname === '/coach/api/vp/connect' && req.method === 'POST') {
|
|
1530
|
+
let body = ''
|
|
1531
|
+
for await (const chunk of req) { body += chunk; if (body.length > 100_000) { req.destroy(); return } }
|
|
1532
|
+
let payload
|
|
1533
|
+
try { payload = JSON.parse(body || '{}') } catch { return sendJson(res, 400, { ok: false, reject: '请求体不是 JSON' }) }
|
|
1534
|
+
try {
|
|
1535
|
+
let result
|
|
1536
|
+
if (payload.op === 'start') result = await vpClient.start({ baseUrl: payload.baseUrl })
|
|
1537
|
+
else if (payload.op === 'finish') { result = await vpClient.finish({ code: payload.code, baseUrl: payload.baseUrl }); switchCoachWorkspace(result.activeAccountId) }
|
|
1538
|
+
else if (payload.op === 'disconnect') { result = vpClient.disconnect(); switchCoachWorkspace('') }
|
|
1539
|
+
else if (payload.op === 'sync') result = await vpClient.sync({})
|
|
1540
|
+
else return sendJson(res, 400, { ok: false, reject: 'op 只能是 start/finish/disconnect/sync' })
|
|
1541
|
+
return sendJson(res, 200, { ok: true, ...result })
|
|
1542
|
+
} catch (err) { return sendJson(res, 400, { ok: false, reject: String(err?.message || err) }) }
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1495
1545
|
if (pathname === '/coach/skilltree.html') {
|
|
1496
1546
|
let html
|
|
1497
1547
|
try {
|
|
@@ -1808,6 +1858,44 @@ function wikiPage(src) {
|
|
|
1808
1858
|
return { ok: true, text: text.slice(0, 12000), truncated: text.length > 12000, chars: text.length }
|
|
1809
1859
|
}
|
|
1810
1860
|
|
|
1861
|
+
const emptyVpAccount = () => ({ id: 0, handle: '', display_name: '' })
|
|
1862
|
+
const vpAccount = (value) => value && Number(value.id) > 0
|
|
1863
|
+
? { id: Number(value.id), handle: String(value.handle || ''), display_name: String(value.display_name || value.handle || '') }
|
|
1864
|
+
: emptyVpAccount()
|
|
1865
|
+
|
|
1866
|
+
function vpEventsFromSync(payload, account) {
|
|
1867
|
+
const submissions = Array.isArray(payload?.submissions) ? payload.submissions : []
|
|
1868
|
+
const contests = Array.isArray(payload?.contests) ? payload.contests : []
|
|
1869
|
+
const byContest = new Map()
|
|
1870
|
+
for (const contest of contests) byContest.set(Number(contest.id), contest)
|
|
1871
|
+
const groups = new Map()
|
|
1872
|
+
for (const submission of submissions) {
|
|
1873
|
+
const contestId = Number(submission.contest_id)
|
|
1874
|
+
const problemId = String(submission.problem_id ?? '').trim()
|
|
1875
|
+
if (!contestId || !problemId) continue
|
|
1876
|
+
if (!groups.has(contestId)) groups.set(contestId, new Map())
|
|
1877
|
+
const problems = groups.get(contestId)
|
|
1878
|
+
if (!problems.has(problemId)) problems.set(problemId, [])
|
|
1879
|
+
problems.get(problemId).push(submission)
|
|
1880
|
+
}
|
|
1881
|
+
return [...groups.entries()].map(([contestId, problems]) => {
|
|
1882
|
+
const contest = byContest.get(contestId) || {}
|
|
1883
|
+
const start = String(contest.start_time || [...problems.values()][0]?.[0]?.submitted_at || '')
|
|
1884
|
+
const normalized = [...problems.entries()].map(([problemId, attempts]) => {
|
|
1885
|
+
const solved = attempts.find((item) => ['AC', 'OK', 'ACCEPTED', 'SOLVED'].includes(String(item.verdict || '').toUpperCase()))
|
|
1886
|
+
const at = solved?.submitted_at || attempts.at(-1)?.submitted_at
|
|
1887
|
+
const minutes = start && at ? Math.max(0, Math.round((Date.parse(at) - Date.parse(start)) / 60000)) : 0
|
|
1888
|
+
return { problemId, status: solved ? 'AC' : String(attempts.at(-1)?.verdict || 'UNKNOWN').toUpperCase(), attempts: attempts.length, solveMinutes: minutes, competencies: [] }
|
|
1889
|
+
})
|
|
1890
|
+
return {
|
|
1891
|
+
eventId: `vp:${account.id}:${contestId}`,
|
|
1892
|
+
contest: String(contest.title || contestId), contestId: String(contestId), date: start.slice(0, 10),
|
|
1893
|
+
durationMinutes: Number(contest.duration_min || 0), virtual: true, teamMode: 'team', problems: normalized,
|
|
1894
|
+
note: '由 VP 只读同步生成;具体能力标签在复盘时补充。',
|
|
1895
|
+
}
|
|
1896
|
+
}).filter((event) => /^\d{4}-\d{2}-\d{2}$/.test(event.date) && event.problems.length)
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1811
1899
|
function apply(ctx) {
|
|
1812
1900
|
// ── 规则层:进 system prompt ──────────────────────────────────────
|
|
1813
1901
|
//
|
|
@@ -1838,6 +1926,93 @@ function apply(ctx) {
|
|
|
1838
1926
|
// 课程图回答“有哪些知识点”,策略层回答“为什么现在学这个”。两者分开,
|
|
1839
1927
|
// 避免把 rating 或游标误当成区域赛目标。
|
|
1840
1928
|
const strategyText = (v) => [{ type: 'text', text: v.ok ? v.text : `❌ ${v.reject}` }]
|
|
1929
|
+
ctx.tools.register(defineTool({
|
|
1930
|
+
name: 'coach_vp_connect',
|
|
1931
|
+
description: '连接 VP 账号。start 返回登录授权页,finish 交换一次性授权码;账号数据按 VP user id 分目录,disconnect 只删本地 token,不删缓存。',
|
|
1932
|
+
parameters: {
|
|
1933
|
+
action: { type: 'string', required: true, enum: ['start', 'finish', 'status', 'disconnect'] },
|
|
1934
|
+
baseUrl: { type: 'string', description: 'VP 地址,默认 https://xiaojing.run' },
|
|
1935
|
+
code: { type: 'string', description: 'VP 授权页显示的一次性 code,action=finish 时填写' },
|
|
1936
|
+
},
|
|
1937
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
1938
|
+
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, action: { type: 'string', required: true },
|
|
1939
|
+
connected: { type: 'boolean', required: true }, activeAccountId: { type: 'integer', required: true },
|
|
1940
|
+
account: { type: 'object', required: true, additionalProperties: false, properties: { id: { type: 'integer', required: true }, handle: { type: 'string', required: true }, display_name: { type: 'string', required: true } } }, requestId: { type: 'string', required: true },
|
|
1941
|
+
authorizeUrl: { type: 'string', required: true }, expiresAt: { type: 'string', required: true }, text: { type: 'string', required: true },
|
|
1942
|
+
} }, render: (_a, v) => strategyText(v) },
|
|
1943
|
+
execute: async (args) => {
|
|
1944
|
+
const action = String(args?.action || 'status')
|
|
1945
|
+
const base = args?.baseUrl || 'https://xiaojing.run'
|
|
1946
|
+
try {
|
|
1947
|
+
let result
|
|
1948
|
+
if (action === 'start') result = await vpClient.start({ baseUrl: base })
|
|
1949
|
+
else if (action === 'finish') {
|
|
1950
|
+
result = await vpClient.finish({ code: args?.code, baseUrl: base })
|
|
1951
|
+
switchCoachWorkspace(result.activeAccountId)
|
|
1952
|
+
} else if (action === 'disconnect') {
|
|
1953
|
+
result = vpClient.disconnect()
|
|
1954
|
+
switchCoachWorkspace('')
|
|
1955
|
+
} else result = await vpClient.status()
|
|
1956
|
+
const account = vpAccount(result.account)
|
|
1957
|
+
return { ok: true, reject: '', action, connected: Boolean(result.connected ?? action === 'finish'), activeAccountId: Number(result.activeAccountId || account.id || 0), account,
|
|
1958
|
+
requestId: String(result.requestId || ''), authorizeUrl: String(result.authorizeUrl || ''), expiresAt: String(result.expiresAt || ''),
|
|
1959
|
+
text: action === 'start' ? `打开授权页登录 VP:${result.authorizeUrl}\n授权后把页面显示的 code 交给 coach_vp_connect finish。` :
|
|
1960
|
+
action === 'finish' ? `VP 已连接:${account.handle}(账号 ${account.id});本地工作区已切换。` :
|
|
1961
|
+
action === 'disconnect' ? 'VP 已断开;缓存保留,token 已删除。' : (result.connected ? `当前 VP:${account.handle}(账号 ${account.id})` : '当前没有已连接的 VP 账号。') }
|
|
1962
|
+
} catch (err) {
|
|
1963
|
+
return { ok: false, reject: String(err?.message || err), action, connected: false, activeAccountId: 0, account: emptyVpAccount(), requestId: '', authorizeUrl: '', expiresAt: '', text: '' }
|
|
1964
|
+
}
|
|
1965
|
+
},
|
|
1966
|
+
}))
|
|
1967
|
+
|
|
1968
|
+
ctx.tools.register(defineTool({
|
|
1969
|
+
name: 'coach_vp_sync',
|
|
1970
|
+
description: '从当前 VP 账号增量同步比赛、提交、事件和个人时间线。只读;游标和缓存都放在当前账号目录,切换账号不会读到另一人的数据。同步后自动生成带账号前缀的 VP 训练证据。',
|
|
1971
|
+
parameters: {
|
|
1972
|
+
limit: { type: 'integer', description: '单次最多同步多少条提交,默认 200' },
|
|
1973
|
+
includeProfile: { type: 'boolean', description: '是否同步个人 rating/比赛历史,默认 true' },
|
|
1974
|
+
},
|
|
1975
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
1976
|
+
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, account: { type: 'object', required: true, additionalProperties: false, properties: { id: { type: 'integer', required: true }, handle: { type: 'string', required: true }, display_name: { type: 'string', required: true } } },
|
|
1977
|
+
imported: { type: 'integer', required: true }, contests: { type: 'array', required: true }, submissions: { type: 'array', required: true }, events: { type: 'array', required: true },
|
|
1978
|
+
nextCursor: { type: 'object', required: true, additionalProperties: true }, cachedPath: { type: 'string', required: true }, text: { type: 'string', required: true },
|
|
1979
|
+
} }, render: (_a, v) => strategyText(v) },
|
|
1980
|
+
execute: async (args) => {
|
|
1981
|
+
try {
|
|
1982
|
+
const payload = await vpClient.sync({ limit: args?.limit, includeProfile: args?.includeProfile !== false })
|
|
1983
|
+
const account = vpAccount(payload.account)
|
|
1984
|
+
switchCoachWorkspace(account.id)
|
|
1985
|
+
const importedEvents = vpEventsFromSync(payload, account)
|
|
1986
|
+
const imported = importedEvents.length ? strategy.vpImport(importedEvents) : { imported: 0 }
|
|
1987
|
+
return { ok: true, reject: '', account, imported: Number(imported.imported || 0), contests: payload.contests || [], submissions: payload.submissions || [], events: payload.events || [],
|
|
1988
|
+
nextCursor: payload.next_cursor || {}, cachedPath: String(payload.cachedPath || ''), text: `VP 同步完成:${account.handle};比赛 ${payload.contests?.length || 0} 场,提交 ${payload.submissions?.length || 0} 条,新增训练证据 ${imported.imported || 0} 场。` }
|
|
1989
|
+
} catch (err) {
|
|
1990
|
+
return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), imported: 0, contests: [], submissions: [], events: [], nextCursor: {}, cachedPath: '', text: '' }
|
|
1991
|
+
}
|
|
1992
|
+
},
|
|
1993
|
+
}))
|
|
1994
|
+
|
|
1995
|
+
ctx.tools.register(defineTool({
|
|
1996
|
+
name: 'coach_vp_replay',
|
|
1997
|
+
description: '读取当前 VP 账号指定比赛的细粒度回放。返回题目、队伍提交和秒级时间轴;代码不回传,页面可视化从这个数据绘制。',
|
|
1998
|
+
parameters: { contestId: { type: 'integer', required: true }, limit: { type: 'integer', description: '官方 runs 上限,默认 50000' } },
|
|
1999
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
2000
|
+
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, account: { type: 'object', required: true, additionalProperties: false, properties: { id: { type: 'integer', required: true }, handle: { type: 'string', required: true }, display_name: { type: 'string', required: true } } },
|
|
2001
|
+
contest: { type: 'object', required: true, additionalProperties: true }, problems: { type: 'array', required: true }, result: { type: 'object', required: true, additionalProperties: true },
|
|
2002
|
+
visualization: { type: 'object', required: true, additionalProperties: true }, cachedPath: { type: 'string', required: true }, text: { type: 'string', required: true },
|
|
2003
|
+
} }, render: (_a, v) => strategyText(v) },
|
|
2004
|
+
execute: async (args) => {
|
|
2005
|
+
try {
|
|
2006
|
+
const payload = await vpClient.replay({ contestId: args?.contestId, limit: args?.limit })
|
|
2007
|
+
const account = vpAccount(payload.account)
|
|
2008
|
+
return { ok: true, reject: '', account, contest: payload.contest || {}, problems: payload.problems || [], result: payload.result || {}, visualization: payload.visualization || {}, cachedPath: String(payload.cachedPath || ''),
|
|
2009
|
+
text: `回放已加载:${payload.contest?.title || args?.contestId};队伍事件 ${payload.visualization?.team_events?.length || 0} 个,官方 runs ${payload.visualization?.official_run_count || 0} 条。` }
|
|
2010
|
+
} catch (err) {
|
|
2011
|
+
return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), contest: {}, problems: [], result: {}, visualization: {}, cachedPath: '', text: '' }
|
|
2012
|
+
}
|
|
2013
|
+
},
|
|
2014
|
+
}))
|
|
2015
|
+
|
|
1841
2016
|
ctx.tools.register(defineTool({
|
|
1842
2017
|
name: 'coach_target',
|
|
1843
2018
|
description: '设置或读取比赛目标。目标先于知识点:contest/date/result/teamMode/weeklyHours/priorities 都确定后,教练才有依据选训练重点。',
|
package/lib/client.js
CHANGED
|
@@ -87,6 +87,7 @@ window.__ModuleLoader__.load({
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
const PAGE_URL = "/coach/skilltree.html";
|
|
90
|
+
const VP_PAGE_URL = "/coach/vp.html";
|
|
90
91
|
|
|
91
92
|
// VP(CompetitionWhale)的品牌标:**拱**是露出水面的鲸背,**波浪**是水面,两笔。
|
|
92
93
|
//
|
|
@@ -191,6 +192,37 @@ window.__ModuleLoader__.load({
|
|
|
191
192
|
});
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
function VpOverlay({ onClose }) {
|
|
196
|
+
React.useEffect(() => {
|
|
197
|
+
const onKey = (e) => { if (e.key === "Escape") onClose(); };
|
|
198
|
+
document.addEventListener("keydown", onKey);
|
|
199
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
200
|
+
}, [onClose]);
|
|
201
|
+
return jsx("div", {
|
|
202
|
+
className: "dshcoach-mask",
|
|
203
|
+
onClick: (e) => { if (e.target === e.currentTarget) onClose(); },
|
|
204
|
+
children: jsxs("div", {
|
|
205
|
+
className: "dshcoach-panel",
|
|
206
|
+
children: [
|
|
207
|
+
jsxs("div", { className: "dshcoach-bar", children: [
|
|
208
|
+
jsxs("div", { className: "dshcoach-title", children: [jsx(WhaleMark, { size: 17 }), jsx("span", { children: "VP 回放" })] }),
|
|
209
|
+
jsx("button", { type: "button", className: "dshcoach-action", onClick: () => window.open(VP_PAGE_URL, "_blank", "noopener"), children: "新标签页打开" }),
|
|
210
|
+
jsx("button", { type: "button", className: "dshcoach-action", onClick: onClose, children: "✕ 关闭" }),
|
|
211
|
+
] }),
|
|
212
|
+
jsx("iframe", { className: "dshcoach-frame", src: VP_PAGE_URL, title: "VP 回放" }),
|
|
213
|
+
],
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function VpHeaderButton() {
|
|
219
|
+
const [open, setOpen] = React.useState(false);
|
|
220
|
+
return jsxs(React.Fragment, { children: [
|
|
221
|
+
jsxs("button", { type: "button", className: "dshcoach-btn", title: "连接 VP、同步比赛并查看秒级回放", onClick: () => setOpen(true), children: [jsx(WhaleMark, { size: 15 }), jsx("span", { children: "VP 回放" })] }),
|
|
222
|
+
open ? createPortal(jsx(VpOverlay, { onClose: () => setOpen(false) }), document.body) : null,
|
|
223
|
+
] });
|
|
224
|
+
}
|
|
225
|
+
|
|
194
226
|
// 服务名注入(不是模块名):等 slots 服务到位再注册。
|
|
195
227
|
const inject = ["slots"];
|
|
196
228
|
|
|
@@ -202,6 +234,12 @@ window.__ModuleLoader__.load({
|
|
|
202
234
|
order: 20,
|
|
203
235
|
registrant: "acmer-coach",
|
|
204
236
|
}, SkillTreeHeaderButton));
|
|
237
|
+
ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
|
|
238
|
+
name: "conversation.session.header.utilities",
|
|
239
|
+
id: "acmer-coach:vp",
|
|
240
|
+
order: 21,
|
|
241
|
+
registrant: "acmer-coach",
|
|
242
|
+
}, VpHeaderButton));
|
|
205
243
|
}
|
|
206
244
|
|
|
207
245
|
return { apply, inject };
|
package/lib/vp-client.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BASE_URL = 'https://xiaojing.run'
|
|
5
|
+
|
|
6
|
+
const cleanBaseUrl = (value) => {
|
|
7
|
+
const url = new URL(String(value || DEFAULT_BASE_URL))
|
|
8
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
9
|
+
throw new Error('VP baseUrl 必须是干净的 http/https 地址')
|
|
10
|
+
}
|
|
11
|
+
return url.toString().replace(/\/$/, '')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function safeAccount(value) {
|
|
15
|
+
if (!value || typeof value !== 'object' || !Number.isInteger(Number(value.id))) throw new Error('VP 返回了无效账号')
|
|
16
|
+
return { id: Number(value.id), handle: String(value.handle || ''), display_name: String(value.display_name || value.handle || '') }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function atomicJson(path, value, mode = 0o600) {
|
|
20
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
|
|
21
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
22
|
+
try { chmodSync(tmp, mode) } catch { /* Windows 没有 Unix mode,不影响原子写 */ }
|
|
23
|
+
try { renameSync(tmp, path) } catch (err) {
|
|
24
|
+
if (err.code !== 'EPERM' && err.code !== 'EEXIST') throw err
|
|
25
|
+
try { unlinkSync(path) } catch (removeErr) { if (removeErr.code !== 'ENOENT') throw err }
|
|
26
|
+
copyFileSync(tmp, path)
|
|
27
|
+
unlinkSync(tmp)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createVpClient({ dataDir, fetchImpl = globalThis.fetch, now = () => new Date() } = {}) {
|
|
32
|
+
if (typeof fetchImpl !== 'function') throw new Error('VP 客户端需要 fetch')
|
|
33
|
+
const root = join(dataDir, 'vp-accounts')
|
|
34
|
+
const registryPath = join(dataDir, 'VP_ACCOUNTS.json')
|
|
35
|
+
|
|
36
|
+
const loadRegistry = () => {
|
|
37
|
+
try {
|
|
38
|
+
const doc = JSON.parse(readFileSync(registryPath, 'utf8'))
|
|
39
|
+
return doc && typeof doc === 'object' ? doc : { activeAccountId: '', accounts: {} }
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err.code === 'ENOENT') return { version: 1, activeAccountId: '', accounts: {} }
|
|
42
|
+
throw err
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const saveRegistry = (value) => atomicJson(registryPath, { version: 1, ...value })
|
|
46
|
+
const accountDir = (id) => join(root, String(id))
|
|
47
|
+
const tokenPath = (id) => join(accountDir(id), 'TOKEN.json')
|
|
48
|
+
const loadToken = (id) => JSON.parse(readFileSync(tokenPath(id), 'utf8')).access_token
|
|
49
|
+
const active = () => {
|
|
50
|
+
const registry = loadRegistry()
|
|
51
|
+
const id = Number(registry.activeAccountId || 0)
|
|
52
|
+
if (!id) return null
|
|
53
|
+
const meta = registry.accounts?.[String(id)]
|
|
54
|
+
if (!meta) return null
|
|
55
|
+
return { id, account: safeAccount(meta.account), baseUrl: cleanBaseUrl(meta.baseUrl || DEFAULT_BASE_URL) }
|
|
56
|
+
}
|
|
57
|
+
const request = async (baseUrl, path, { token, method = 'GET', body } = {}) => {
|
|
58
|
+
const response = await fetchImpl(`${cleanBaseUrl(baseUrl)}${path}`, {
|
|
59
|
+
method, headers: { accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
60
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
61
|
+
})
|
|
62
|
+
let payload
|
|
63
|
+
try { payload = await response.json() } catch { payload = { detail: await response.text() } }
|
|
64
|
+
if (!response.ok) throw new Error(`VP ${response.status}: ${payload?.detail || payload?.message || '请求失败'}`)
|
|
65
|
+
return payload
|
|
66
|
+
}
|
|
67
|
+
const requireActive = () => {
|
|
68
|
+
const current = active()
|
|
69
|
+
if (!current) throw new Error('还没有连接 VP;先调用 coach_vp_connect start/finish')
|
|
70
|
+
return { ...current, token: loadToken(current.id) }
|
|
71
|
+
}
|
|
72
|
+
const verifyAccount = (current, value) => {
|
|
73
|
+
const returned = safeAccount(value)
|
|
74
|
+
if (returned.id !== current.id) throw new Error(`VP account mismatch: 当前是 ${current.id},接口返回 ${returned.id}`)
|
|
75
|
+
return returned
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
async start({ baseUrl = DEFAULT_BASE_URL } = {}) {
|
|
80
|
+
const clean = cleanBaseUrl(baseUrl)
|
|
81
|
+
const result = await request(clean, '/api/coach/auth/start', { method: 'POST', body: {
|
|
82
|
+
redirect_uri: 'http://127.0.0.1:8765/coach/callback',
|
|
83
|
+
scopes: ['profile', 'contests', 'submissions', 'replay'],
|
|
84
|
+
} })
|
|
85
|
+
return { ok: true, requestId: result.request_id, authorizeUrl: result.authorize_url, expiresAt: result.expires_at, baseUrl: clean }
|
|
86
|
+
},
|
|
87
|
+
async finish({ code, baseUrl = DEFAULT_BASE_URL } = {}) {
|
|
88
|
+
const clean = cleanBaseUrl(baseUrl)
|
|
89
|
+
const result = await request(clean, '/api/coach/auth/exchange', { method: 'POST', body: { code: String(code || '') } })
|
|
90
|
+
const account = safeAccount(result.user)
|
|
91
|
+
const dir = accountDir(account.id)
|
|
92
|
+
mkdirSync(dir, { recursive: true })
|
|
93
|
+
atomicJson(tokenPath(account.id), { access_token: String(result.access_token), saved_at: now().toISOString() })
|
|
94
|
+
const registry = loadRegistry()
|
|
95
|
+
registry.accounts = registry.accounts || {}
|
|
96
|
+
registry.accounts[String(account.id)] = { account, baseUrl: clean, lastSyncAt: registry.accounts[String(account.id)]?.lastSyncAt || '' }
|
|
97
|
+
registry.activeAccountId = account.id
|
|
98
|
+
saveRegistry(registry)
|
|
99
|
+
return { ok: true, account, activeAccountId: account.id }
|
|
100
|
+
},
|
|
101
|
+
async status() {
|
|
102
|
+
const current = active()
|
|
103
|
+
if (!current) return { connected: false, account: null, activeAccountId: null }
|
|
104
|
+
try {
|
|
105
|
+
const remote = await request(current.baseUrl, '/api/coach/me', { token: loadToken(current.id) })
|
|
106
|
+
const account = verifyAccount(current, remote.user)
|
|
107
|
+
return { connected: true, account, activeAccountId: current.id, baseUrl: current.baseUrl, readOnly: remote.read_only !== false }
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return { connected: false, account: current.account, activeAccountId: current.id, error: err.message }
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
async sync({ limit = 200, includeProfile = true } = {}) {
|
|
113
|
+
const current = requireActive()
|
|
114
|
+
const registry = loadRegistry()
|
|
115
|
+
const accountMeta = registry.accounts[String(current.id)] || {}
|
|
116
|
+
const cursor = accountMeta.cursor || { submission_id: 0, event_id: 0 }
|
|
117
|
+
const query = new URLSearchParams({ submission_cursor: String(cursor.submission_id || 0), event_cursor: String(cursor.event_id || 0), limit: String(Math.max(1, Math.min(500, Number(limit) || 200))), include_profile: includeProfile ? '1' : '0' })
|
|
118
|
+
const payload = await request(current.baseUrl, `/api/coach/sync?${query}`, { token: current.token })
|
|
119
|
+
const account = verifyAccount(current, payload.account)
|
|
120
|
+
const next = payload.next_cursor || cursor
|
|
121
|
+
atomicJson(join(accountDir(current.id), 'SYNC.json'), { ...payload, account, synced_at: now().toISOString() })
|
|
122
|
+
registry.accounts[String(current.id)] = { ...accountMeta, account, baseUrl: current.baseUrl, cursor: next, lastSyncAt: now().toISOString() }
|
|
123
|
+
saveRegistry(registry)
|
|
124
|
+
return { ...payload, account, cursor: next, cachedPath: join(accountDir(current.id), 'SYNC.json') }
|
|
125
|
+
},
|
|
126
|
+
async replay({ contestId, limit = 50000 } = {}) {
|
|
127
|
+
const current = requireActive()
|
|
128
|
+
const payload = await request(current.baseUrl, `/api/coach/replay/${encodeURIComponent(String(contestId))}?limit=${Math.max(1, Math.min(50000, Number(limit) || 50000))}`, { token: current.token })
|
|
129
|
+
const account = verifyAccount(current, payload.account)
|
|
130
|
+
const result = { ...payload, account, cachedPath: join(accountDir(current.id), `REPLAY-${String(contestId)}.json`) }
|
|
131
|
+
atomicJson(result.cachedPath, { ...payload, account, cached_at: now().toISOString() })
|
|
132
|
+
return result
|
|
133
|
+
},
|
|
134
|
+
disconnect() {
|
|
135
|
+
const current = active()
|
|
136
|
+
if (!current) return { ok: true, disconnected: false }
|
|
137
|
+
try { rmSync(tokenPath(current.id), { force: true }) } catch { /* best effort */ }
|
|
138
|
+
const registry = loadRegistry()
|
|
139
|
+
registry.activeAccountId = ''
|
|
140
|
+
saveRegistry(registry)
|
|
141
|
+
return { ok: true, disconnected: true, account: current.account, cached: true }
|
|
142
|
+
},
|
|
143
|
+
localStatus() {
|
|
144
|
+
const current = active()
|
|
145
|
+
return current ? { connected: true, account: current.account, activeAccountId: current.id, baseUrl: current.baseUrl } : { connected: false, account: null, activeAccountId: null }
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
package/package.json
CHANGED
package/verify-shape.mjs
CHANGED
|
@@ -175,6 +175,9 @@ const cases = [
|
|
|
175
175
|
['coach_assign#target-gate', { node: 'B', deliverable: '把代码贴给我', why: '测试目标硬闸', minutes: 60, teachMinutes: 25 }],
|
|
176
176
|
['coach_target', { action: 'set', expectedRevision: 2, contest: '测试区域赛', date: '2026-10-18', result: '金牌', teamMode: 'team', weeklyHours: 20, priorities: [{ id: 'contest-reading', weight: 80, reason: '测试目标' }] }],
|
|
177
177
|
['coach_vp_import', { events: [{ eventId: 'shape-vp', contest: '测试 VP', date: today, durationMinutes: 300, problems: [{ problemId: 'A', status: 'AC', competencies: ['contest-reading'] }] }] }],
|
|
178
|
+
['coach_vp_connect', { action: 'status' }],
|
|
179
|
+
['coach_vp_sync', {}],
|
|
180
|
+
['coach_vp_replay', { contestId: 1 }],
|
|
178
181
|
['coach_postmortem', { eventId: 'shape-vp', competencies: ['contest-reading'], rootCauses: ['测试复盘'], nextActions: ['测试动作'] }],
|
|
179
182
|
['coach_focus', {}],
|
|
180
183
|
['coach_scope', { id: 'contest-reading' }],
|