acmer-coach 2.2.1 → 2.3.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/README.md +3 -2
- package/assets/knowledge/vp.html +50 -37
- package/assets/rules/coach-rules.md +2 -1
- package/dev/vp-client-check.mjs +2 -0
- package/index.js +27 -0
- package/lib/vp-client.js +6 -0
- package/package.json +1 -1
- package/verify-shape.mjs +1 -0
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
## 它到底做了什么
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
30 个工具,三条轴:知识图、比赛能力、训练证据。
|
|
24
24
|
|
|
25
25
|
| 轴 | 含义 |
|
|
26
26
|
|---|---|
|
|
@@ -103,7 +103,8 @@ learned/verified 记录,或路线中在此前安排的必验训练,不能从
|
|
|
103
103
|
现在可以直接连接 VP:`coach_vp_connect start` 打开授权页,登录并允许只读访问后,
|
|
104
104
|
把一次性 code 交给 `coach_vp_connect finish`。然后用 `coach_vp_sync` 增量同步比赛、
|
|
105
105
|
提交、事件和个人历史;同步会自动生成带账号前缀的 VP 训练证据,仍由复盘和检测决定
|
|
106
|
-
|
|
106
|
+
能力结论。小鲸先用 `coach_vp_contests` 按比赛名称选场,再调用 `coach_vp_replay`,不需要你记 Contest ID。
|
|
107
|
+
`VP 回放`按钮会打开可视化页面,
|
|
107
108
|
把 AC、WA/TLE/RE 和比赛分钟画在同一条时间轴上。
|
|
108
109
|
|
|
109
110
|
每个 VP 账号都放在 `~/.dsh/knowledge/vp-accounts/<user_id>/`,token 单独保存,
|
package/assets/knowledge/vp.html
CHANGED
|
@@ -3,45 +3,58 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
-
<title>VP
|
|
6
|
+
<title>VP Lab · 小鲸教练</title>
|
|
7
7
|
<style>
|
|
8
|
-
:root { color-scheme: dark; --
|
|
9
|
-
* { box-sizing:border-box } body { margin:0;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
#
|
|
16
|
-
.
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
:root { color-scheme: dark; --ink:#edf3f8; --muted:#8d9aa8; --surface:#111a24; --surface-2:#172432; --line:#263747; --cyan:#6de0db; --coral:#ff8b78; --gold:#f6c76b; }
|
|
9
|
+
* { box-sizing:border-box } body { margin:0; min-height:100vh; color:var(--ink); background:#091119; font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; }
|
|
10
|
+
body:before { content:""; position:fixed; inset:0; pointer-events:none; background:radial-gradient(900px 460px at 72% -8%,#15404a55,transparent 65%),radial-gradient(620px 420px at 0 45%,#24314d38,transparent 72%); }
|
|
11
|
+
button,input { font:inherit } button { cursor:pointer; border:0 } .app { position:relative; display:grid; grid-template-columns:300px minmax(0,1fr); min-height:100vh; max-width:1500px; margin:auto; }
|
|
12
|
+
.rail { border-right:1px solid var(--line); background:#0d1721cc; backdrop-filter:blur(18px); padding:26px 18px 20px; display:flex; flex-direction:column; }
|
|
13
|
+
.brand { display:flex; align-items:center; gap:11px; padding:0 8px 25px; border-bottom:1px solid var(--line); } .mark { width:35px; height:35px; display:grid; place-items:center; border:1px solid #6de0db66; border-radius:12px; color:var(--cyan); background:#14383a; font-weight:800; letter-spacing:-2px } .eyebrow { color:var(--cyan); text-transform:uppercase; letter-spacing:.15em; font-size:10px; font-weight:700 } .brand h1 { margin:2px 0 0; font-size:18px; letter-spacing:-.02em }
|
|
14
|
+
.rail-head { display:flex; align-items:center; justify-content:space-between; padding:22px 8px 12px; } .rail-head h2 { margin:0; font-size:13px; color:#c8d4df } .icon-btn { width:30px; height:30px; border-radius:8px; color:var(--muted); background:transparent; border:1px solid transparent } .icon-btn:hover { color:var(--ink); border-color:var(--line); background:var(--surface-2) }
|
|
15
|
+
#contests { display:flex; flex-direction:column; gap:8px; overflow:auto; padding:0 2px; } .contest { text-align:left; width:100%; padding:12px; border-radius:11px; border:1px solid transparent; color:var(--ink); background:transparent } .contest:hover { background:#17243288; border-color:var(--line) } .contest.active { background:#183438; border-color:#3e8582; box-shadow:inset 3px 0 var(--cyan) } .contest-title { display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:650; } .contest-meta { display:flex; justify-content:space-between; gap:6px; margin-top:5px; color:var(--muted); font-size:12px } .tag { color:var(--gold); }
|
|
16
|
+
.empty { color:var(--muted); padding:17px 10px; font-size:13px; } .rail-foot { margin-top:auto; padding:15px 8px 0; border-top:1px solid var(--line); color:var(--muted); font-size:12px; } .account-line { display:flex; align-items:center; gap:8px; color:#c9d7e3; margin-bottom:10px } .avatar { width:23px; height:23px; border-radius:50%; display:grid; place-items:center; color:#081116; background:var(--cyan); font-size:11px; font-weight:800 }
|
|
17
|
+
.main { min-width:0; padding:29px clamp(22px,4vw,58px) 45px; } .top { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; margin-bottom:30px } .top h2 { margin:5px 0 6px; font-size:31px; line-height:1.1; letter-spacing:-.045em } .top p { margin:0; color:var(--muted); max-width:570px } .status { display:flex; align-items:center; gap:8px; padding:8px 12px; border:1px solid var(--line); border-radius:999px; color:var(--muted); background:#101b26aa; white-space:nowrap; font-size:12px } .status i { width:7px; height:7px; border-radius:50%; background:var(--coral); box-shadow:0 0 12px var(--coral) } .status.ok { color:#bcece1; border-color:#31716d; } .status.ok i { background:var(--cyan); box-shadow:0 0 12px var(--cyan) }
|
|
18
|
+
.hero { display:grid; grid-template-columns:minmax(0,1fr) 230px; gap:17px; margin-bottom:17px; } .hero-card,.panel { border:1px solid var(--line); border-radius:17px; background:linear-gradient(145deg,#172633ee,#0f1923ee); box-shadow:0 18px 60px #00000035; } .hero-card { padding:24px; min-height:190px; position:relative; overflow:hidden; } .hero-card:after { content:""; position:absolute; width:280px; height:280px; right:-110px; top:-150px; border-radius:50%; border:1px solid #6de0db24; box-shadow:0 0 0 22px #6de0db0d,0 0 0 44px #6de0db08; } .hero-card h3 { margin:9px 0 5px; max-width:75%; font-size:25px; letter-spacing:-.04em } .hero-card .sub { color:var(--muted); } .hero-date { color:var(--cyan); font-size:12px; letter-spacing:.02em } .hero-side { padding:20px; display:flex; flex-direction:column; justify-content:space-between; } .hero-side .label { color:var(--muted); font-size:12px } .hero-side strong { display:block; margin-top:5px; font-size:30px; letter-spacing:-.06em } .hero-side .small { color:var(--muted); font-size:12px }
|
|
19
|
+
.metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-top:20px; max-width:760px } .metric { padding:11px 13px; border:1px solid #2b3c4b; border-radius:10px; background:#0d1721aa } .metric span { display:block; color:var(--muted); font-size:11px } .metric b { display:block; margin-top:1px; font-size:20px; letter-spacing:-.04em }
|
|
20
|
+
.panel { padding:20px; margin-bottom:17px; } .panel-head { display:flex; align-items:end; justify-content:space-between; gap:12px; margin-bottom:16px } .panel-head h3 { margin:0; font-size:15px } .panel-head span { color:var(--muted); font-size:12px } #timeline { min-height:235px; border-radius:12px; background:#0b141d; border:1px solid #223342; padding:10px 12px; overflow:auto } svg { width:100%; min-width:700px; height:210px } .legend { display:flex; gap:15px; color:var(--muted); font-size:11px; padding:4px 7px 0 } .dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:4px }
|
|
21
|
+
.below { display:grid; grid-template-columns:1.15fr .85fr; gap:17px } .problem { display:grid; grid-template-columns:32px minmax(0,1fr) auto; align-items:center; gap:10px; padding:11px 0; border-bottom:1px solid #22303d } .problem:last-child { border-bottom:0 } .problem-letter { display:grid; place-items:center; width:28px; height:28px; border-radius:8px; color:#071015; background:var(--gold); font-weight:800 } .problem-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap } .verdict { padding:3px 8px; border-radius:999px; color:#bcece1; background:#153d3b; font-size:11px }
|
|
22
|
+
.connect { display:flex; flex-direction:column; justify-content:center; min-height:190px; padding:26px; border:1px dashed #3c596d; border-radius:17px; background:#101e2b99 } .connect h3 { margin:0 0 6px; font-size:20px } .connect p { margin:0 0 17px; color:var(--muted); max-width:530px } .primary,.secondary { border-radius:9px; padding:10px 14px; font-weight:650 } .primary { color:#071015; background:var(--cyan); } .primary:hover { background:#9af1ea } .secondary { color:#cbd8e4; border:1px solid var(--line); background:#182635; } .secondary:hover { border-color:#55758c; background:#203448 } .actions { display:flex; flex-wrap:wrap; gap:8px }
|
|
23
|
+
.modal { position:fixed; inset:0; z-index:5; display:grid; place-items:center; padding:20px; background:#03080dcc; backdrop-filter:blur(9px) } .modal-card { width:min(470px,100%); padding:23px; border:1px solid #416172; border-radius:16px; background:#132331; box-shadow:0 24px 80px #0008 } .modal-card h3 { margin:0 0 7px; font-size:21px } .modal-card p { color:var(--muted); margin:0 0 17px } input { width:100%; padding:11px 12px; color:var(--ink); border:1px solid var(--line); border-radius:8px; outline:0; background:#0c151e } input:focus { border-color:var(--cyan); box-shadow:0 0 0 3px #6de0db1c } .modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:13px } #message,#modalMsg { min-height:21px; margin-top:13px; color:var(--muted); font-size:12px }
|
|
24
|
+
[hidden] { display:none !important } @media (max-width:900px) { .app{grid-template-columns:235px 1fr}.hero{grid-template-columns:1fr}.hero-side{min-height:130px}.below{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)} } @media (max-width:650px) { .app{display:block}.rail{border-right:0;border-bottom:1px solid var(--line);padding:16px}.rail-foot{display:none}.main{padding:23px 15px}.top{display:block}.status{display:inline-flex;margin-top:15px}.top h2{font-size:26px}.hero-card,.panel{border-radius:13px} }
|
|
19
25
|
</style>
|
|
20
26
|
</head>
|
|
21
|
-
<body
|
|
22
|
-
|
|
23
|
-
<
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
<body>
|
|
28
|
+
<div class="app">
|
|
29
|
+
<aside class="rail">
|
|
30
|
+
<div class="brand"><div class="mark">鲸</div><div><div class="eyebrow">ACMER COACH</div><h1>VP Lab</h1></div></div>
|
|
31
|
+
<div class="rail-head"><h2>我的 VP 比赛</h2><button id="refresh" class="icon-btn" title="刷新比赛列表" aria-label="刷新比赛列表">↻</button></div>
|
|
32
|
+
<div id="contests"><div class="empty">正在读取比赛…</div></div>
|
|
33
|
+
<div class="rail-foot"><div class="account-line"><span id="avatar" class="avatar">?</span><span id="accountName">未连接 VP</span></div><span>选择比赛后,小鲸可以直接读取同一场回放。</span></div>
|
|
34
|
+
</aside>
|
|
35
|
+
<main class="main">
|
|
36
|
+
<header class="top"><div><div class="eyebrow">REPLAY WORKSPACE</div><h2>把比赛过程摊开看</h2><p>回放不是给你查编号的。选一场 VP,小鲸会拿到提交节奏、失败位置和队伍时间线,用它来做复盘和下一周训练安排。</p></div><div id="status" class="status"><i></i><span>检查账号</span></div></header>
|
|
37
|
+
<section id="connectCard" class="connect" hidden><h3>先连接你的 VP</h3><p>只读授权。密码留在 VP,教练拿到的是独立 token;账号切换后,历史缓存不会混在一起。</p><div class="actions"><button id="start" class="primary">打开 VP 登录授权</button><button id="syncEmpty" class="secondary">我已经有 code</button></div></section>
|
|
38
|
+
<section id="workspace" hidden>
|
|
39
|
+
<div class="hero"><div class="hero-card"><div class="hero-date" id="heroDate">等待选择比赛</div><h3 id="heroTitle">从左侧选一场 VP</h3><div class="sub" id="heroSub">这里会显示比赛状态、队伍和训练上下文。</div><div id="metrics" class="metrics"></div></div><div class="hero-side panel"><div><div class="label">当前视角</div><strong id="heroRank">—</strong><div class="small">你的队伍 · 比赛结果</div></div><div class="small" id="heroStatus">未载入回放</div></div></div>
|
|
40
|
+
<div class="panel"><div class="panel-head"><h3>比赛节奏</h3><span id="timelineHint">选择比赛后自动加载</span></div><div id="timeline"></div></div>
|
|
41
|
+
<div class="below"><div class="panel"><div class="panel-head"><h3>题目轨迹</h3><span id="problemHint">—</span></div><div id="problems"><div class="empty">选择比赛后显示</div></div></div><div class="panel"><div class="panel-head"><h3>给小鲸的入口</h3><span>分析建议</span></div><p class="muted" style="margin:0 0 14px">小鲸可以直接调用 <code>coach_vp_replay</code> 获取当前比赛的秒级事件,再结合 <code>coach_postmortem</code> 把事实变成训练动作。</p><div class="actions"><button id="sync" class="primary">同步最新 VP</button><button id="disconnect" class="secondary">切换账号</button></div><div id="message"></div></div></div>
|
|
29
42
|
</section>
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
</div>
|
|
34
|
-
</main>
|
|
43
|
+
</main>
|
|
44
|
+
</div>
|
|
45
|
+
<div id="modal" class="modal" hidden><div class="modal-card"><h3>完成 VP 授权</h3><p>授权页允许读取后,把页面显示的一次性 code 粘贴到这里。</p><input id="code" autocomplete="off" placeholder="粘贴 code"><div class="modal-actions"><button id="cancel" class="secondary">取消</button><button id="finish" class="primary">完成连接</button></div><div id="modalMsg"></div></div></div>
|
|
35
46
|
<script>
|
|
36
|
-
const $ =
|
|
37
|
-
const say = (
|
|
38
|
-
async function api(path, options) { const r=await fetch(path,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
$('
|
|
45
|
-
function render(d){ const v=d.visualization||{}, ev=v.team_events||[], duration=Math.max(60,v.duration_seconds||1),
|
|
46
|
-
|
|
47
|
-
</script
|
|
47
|
+
const $ = id => document.getElementById(id); let selected = null; let contests = [];
|
|
48
|
+
const say = (s,bad=false) => { $('message').textContent=s; $('message').style.color=bad?'var(--coral)':'' }
|
|
49
|
+
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 Error(d.reject||`HTTP ${r.status}`); return d }
|
|
50
|
+
function esc(v){return String(v??'').replace(/[&<>"']/g,x=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[x]))}
|
|
51
|
+
function dateOf(v) { if(!v)return '日期未知'; const raw=String(v).replace(' ','T'); const d=new Date(/[zZ]|[+-]\d\d:\d\d$/.test(raw)?raw:`${raw}+08:00`); return Number.isNaN(d.getTime())?String(v).slice(0,10):d.toLocaleDateString('zh-CN',{month:'short',day:'numeric'}) }
|
|
52
|
+
function setStatus(ok,name='未连接 VP') { $('status').className='status'+(ok?' ok':''); $('status').innerHTML=`<i></i><span>${ok?`已连接 · ${esc(name)}`:'未连接 VP'}</span>`; $('accountName').textContent=ok?name:'未连接 VP'; $('avatar').textContent=ok?name.slice(0,1).toUpperCase():'?'; }
|
|
53
|
+
async function loadStatus() { try { const d=await api('./api/vp/status'); if(!d.connected){setStatus(false);$('connectCard').hidden=false;$('workspace').hidden=true;return false} setStatus(true,d.account.display_name||d.account.handle);$('connectCard').hidden=true;$('workspace').hidden=false;return true } catch(e){say(e.message,true);return false} }
|
|
54
|
+
async function loadContests() { try { const d=await api('./api/vp/contests'); contests=d.contests||[]; $('contests').innerHTML=contests.length?contests.map(c=>`<button class="contest ${selected?.id===c.id?'active':''}" data-id="${c.id}"><span class="contest-title">${esc(c.title)}</span><span class="contest-meta"><span>${dateOf(c.start_time)}</span><span class="tag">${c.status==='ended'?'已结束':'进行中'}</span></span></button>`).join(''):'<div class="empty">还没有可看的 VP 比赛。先点“同步最新 VP”。</div>'; document.querySelectorAll('.contest').forEach(b=>b.onclick=()=>selectContest(Number(b.dataset.id))); } catch(e){$('contests').innerHTML=`<div class="empty">${esc(e.message)}</div>`} }
|
|
55
|
+
async function selectContest(id) { selected=contests.find(c=>c.id===id)||{id}; document.querySelectorAll('.contest').forEach(b=>b.classList.toggle('active',Number(b.dataset.id)===id)); $('heroTitle').textContent=selected.title||`Contest ${id}`; $('heroDate').textContent=`${dateOf(selected.start_time)} · ${selected.duration_min||300} 分钟`; $('heroSub').textContent=`${selected.status==='ended'?'已结束':'进行中'} · 选择后回放会自动加载`; try { const d=await api(`./api/vp/replay?contestId=${encodeURIComponent(id)}`); render(d) } catch(e){say(e.message,true)} }
|
|
56
|
+
function render(d){ const v=d.visualization||{}, ev=v.team_events||[], duration=Math.max(60,v.duration_seconds||1), solved=d.result?.own?.solved??d.result?.solved??0; $('heroStatus').textContent=`${ev.length} 个队伍事件 · ${v.official_run_count||0} 条官方记录`; $('heroRank').textContent=d.result?.rank?`#${d.result.rank}`:'—'; $('metrics').innerHTML=`<div class="metric"><span>解决题数</span><b>${solved}</b></div><div class="metric"><span>队伍提交</span><b>${ev.length}</b></div><div class="metric"><span>官方 runs</span><b>${v.official_run_count||0}</b></div><div class="metric"><span>比赛时长</span><b>${Math.round(duration/60)}m</b></div>`; $('timelineHint').textContent=`${ev.length?'点击圆点看提交详情':'这场比赛暂无队伍提交'} · 横轴为比赛分钟`; const w=920,pad=48; const ticks=Array.from({length:7},(_,i)=>{const x=pad+(w-pad*2)*i/6;return `<line x1="${x}" y1="27" x2="${x}" y2="167" stroke="#22303d"/><text x="${x}" y="188" fill="#8d9aa8" text-anchor="middle">${Math.round(duration*i/6/60)}m</text>`}).join(''); const marks=ev.map((e,i)=>{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?'#6de0db':'#ff8b78';return `<g class="event" tabindex="0" data-event="${i}"><circle cx="${x}" cy="${good?70:119}" r="8" fill="${color}"/><circle cx="${x}" cy="${good?70:119}" r="14" fill="none" stroke="${color}" stroke-opacity=".18"/><title>${esc(e.problem||'')} · ${esc(e.verdict||'')} · ${Math.round((e.second||0)/60)}m</title></g>`}).join(''); $('timeline').innerHTML=`<svg viewBox="0 0 ${w} 205" role="img" aria-label="比赛提交时间轴"><text x="8" y="74" fill="#6de0db">AC</text><text x="8" y="123" fill="#ff8b78">失败</text>${ticks}<line x1="${pad}" y1="70" x2="${w-pad}" y2="70" stroke="#6de0db" stroke-opacity=".65"/><line x1="${pad}" y1="119" x2="${w-pad}" y2="119" stroke="#ff8b78" stroke-opacity=".65"/>${marks}</svg><div class="legend"><span><i class="dot" style="background:#6de0db"></i>AC</span><span><i class="dot" style="background:#ff8b78"></i>WA / TLE / RE</span><span>比赛墙上时间 · 不是评测耗时</span></div>`; document.querySelectorAll('.event').forEach(g=>g.onclick=()=>{const e=ev[Number(g.dataset.event)];say(`${e.problem||''} · ${e.verdict||''} · ${Math.round((e.second||0)/60)} 分钟 · ${e.display_name||''}`)}); $('problemHint').textContent=`${(d.problems||[]).length} 道题`; $('problems').innerHTML=(d.problems||[]).map(p=>`<div class="problem"><span class="problem-letter">${esc(p.label||p.id||'?')}</span><span class="problem-title">${esc(p.title||'未命名题目')}</span><span class="verdict">${esc(p.id||'—')}</span></div>`).join('')||'<div class="empty">没有题目数据</div>' }
|
|
57
|
+
$('start').onclick=async()=>{try{const d=await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'start'})}); window.open(d.authorizeUrl,'_blank','noopener'); $('modal').hidden=false; $('modalMsg').textContent='授权页已打开,登录并允许读取后粘贴 code。'}catch(e){say(e.message,true)}}; $('syncEmpty').onclick=()=>{$('modal').hidden=false}; $('cancel').onclick=()=>{$('modal').hidden=true}; $('finish').onclick=async()=>{try{await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'finish',code:$('code').value})});$('modal').hidden=true;$('code').value='';await loadStatus();await loadContests();say('连接成功,比赛列表已更新。')}catch(e){$('modalMsg').textContent=e.message;$('modalMsg').style.color='var(--coral)'}}; $('refresh').onclick=loadContests; $('sync').onclick=async()=>{try{const d=await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'sync'})});say(`同步完成:${d.submissions?.length||0} 条提交,${d.contests?.length||0} 场比赛。`);await loadContests();if(selected)await selectContest(selected.id)}catch(e){say(e.message,true)}}; $('disconnect').onclick=async()=>{try{await api('./api/vp/connect',{method:'POST',body:JSON.stringify({op:'disconnect'})});selected=null;await loadStatus()}catch(e){say(e.message,true)}}; (async()=>{if(await loadStatus())await loadContests()})()
|
|
58
|
+
</script>
|
|
59
|
+
</body>
|
|
60
|
+
</html>
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
为什么现在排、暂缓什么,以及用什么表现退出。知识图只负责把能力重点展开成可学节点。
|
|
17
17
|
* 有 VP 时先用 `coach_vp_connect status` 检查账号,未连接就走 `start → 登录授权页 → finish`,
|
|
18
18
|
再用 `coach_vp_sync` 拉取真实记录;赛后调 `coach_vp_replay` 看秒级时间轴,最后调
|
|
19
|
-
`coach_postmortem
|
|
19
|
+
`coach_postmortem`。需要回放时先用 `coach_vp_contests` 按比赛名称选择,再调
|
|
20
|
+
`coach_vp_replay`;不要让学生提供 Contest ID。VP 的读题、耗时、提交、换题和团队问题优先于 rating;没有 VP 证据时要
|
|
20
21
|
明确说“尚无记录”,不能装作知道短板。手工导入仍可用 `coach_vp_import`,但不能和当前账号记录混用。
|
|
21
22
|
* 讲知识点之前调 `coach_scope`。必须说明适用范围、延伸、反例、常见错误、迁移题和退出
|
|
22
23
|
条件;工具返回“没有范围卡片”时不许凭记忆补一段看似完整的范围。
|
package/dev/vp-client-check.mjs
CHANGED
|
@@ -16,6 +16,7 @@ const fetchImpl = async (url, options = {}) => {
|
|
|
16
16
|
return json({ access_token: `token-${account.id}`, user: account })
|
|
17
17
|
}
|
|
18
18
|
if (path.endsWith('/api/coach/me')) return json({ user: account, read_only: true })
|
|
19
|
+
if (path.endsWith('/api/coach/contests')) return json({ account, contests: [{ id: 7, title: 'VP 7', status: 'ended' }] })
|
|
19
20
|
if (path.endsWith('/api/coach/sync')) return json({
|
|
20
21
|
account: mismatch ? { id: 1, handle: 'A' } : account,
|
|
21
22
|
submissions: [{ id: 9, contest_id: 7, verdict: 'AC' }], events: [], contests: [],
|
|
@@ -36,6 +37,7 @@ try {
|
|
|
36
37
|
assert.equal(JSON.parse(readFileSync(join(root, 'vp-accounts', '1', 'SYNC.json'))).account.id, 1)
|
|
37
38
|
await client.finish({ code: 'B' })
|
|
38
39
|
assert.equal((await client.status()).account.id, 2)
|
|
40
|
+
assert.equal((await client.contests()).contests[0].id, 7)
|
|
39
41
|
assert.equal((await client.sync()).account.id, 2)
|
|
40
42
|
mismatch = true
|
|
41
43
|
await assert.rejects(() => client.sync(), /account mismatch/)
|
package/index.js
CHANGED
|
@@ -1519,6 +1519,11 @@ function registerPages(ctx) {
|
|
|
1519
1519
|
return sendJson(res, 200, { ok: true, ...vpClient.localStatus() })
|
|
1520
1520
|
}
|
|
1521
1521
|
|
|
1522
|
+
if (pathname === '/coach/api/vp/contests' && req.method === 'GET') {
|
|
1523
|
+
try { return sendJson(res, 200, { ok: true, ...(await vpClient.contests()) }) }
|
|
1524
|
+
catch (err) { return sendJson(res, 400, { ok: false, reject: String(err?.message || err), contests: [] }) }
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1522
1527
|
if (pathname === '/coach/api/vp/replay' && req.method === 'GET') {
|
|
1523
1528
|
const id = new URL(req.url ?? '/', 'http://dsh.internal').searchParams.get('contestId')
|
|
1524
1529
|
if (!id) return sendJson(res, 400, { ok: false, reject: '缺少 contestId' })
|
|
@@ -1992,6 +1997,28 @@ function apply(ctx) {
|
|
|
1992
1997
|
},
|
|
1993
1998
|
}))
|
|
1994
1999
|
|
|
2000
|
+
ctx.tools.register(defineTool({
|
|
2001
|
+
name: 'coach_vp_contests',
|
|
2002
|
+
description: '列出当前 VP 账号参加过的比赛,按最近开始时间排序。小鲸在调用 coach_vp_replay 前先用它拿到可读的比赛名称,不要求用户记 Contest ID。',
|
|
2003
|
+
parameters: {},
|
|
2004
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
2005
|
+
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 } } },
|
|
2006
|
+
contests: { type: 'array', required: true }, text: { type: 'string', required: true },
|
|
2007
|
+
} }, render: (_a, v) => strategyText(v) },
|
|
2008
|
+
execute: async () => {
|
|
2009
|
+
try {
|
|
2010
|
+
const payload = await vpClient.contests()
|
|
2011
|
+
const account = vpAccount(payload.account)
|
|
2012
|
+
const contests = payload.contests || []
|
|
2013
|
+
return { ok: true, reject: '', account, contests, text: contests.length
|
|
2014
|
+
? `当前账号有 ${contests.length} 场 VP:\n${contests.slice(0, 20).map((item, i) => `${i + 1}. ${item.title}(${item.start_time || '日期未知'},id=${item.id})`).join('\n')}`
|
|
2015
|
+
: '当前账号没有可读取的 VP 比赛。' }
|
|
2016
|
+
} catch (err) {
|
|
2017
|
+
return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), contests: [], text: '' }
|
|
2018
|
+
}
|
|
2019
|
+
},
|
|
2020
|
+
}))
|
|
2021
|
+
|
|
1995
2022
|
ctx.tools.register(defineTool({
|
|
1996
2023
|
name: 'coach_vp_replay',
|
|
1997
2024
|
description: '读取当前 VP 账号指定比赛的细粒度回放。返回题目、队伍提交和秒级时间轴;代码不回传,页面可视化从这个数据绘制。',
|
package/lib/vp-client.js
CHANGED
|
@@ -109,6 +109,12 @@ export function createVpClient({ dataDir, fetchImpl = globalThis.fetch, now = ()
|
|
|
109
109
|
return { connected: false, account: current.account, activeAccountId: current.id, error: err.message }
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
|
+
async contests() {
|
|
113
|
+
const current = requireActive()
|
|
114
|
+
const payload = await request(current.baseUrl, '/api/coach/contests', { token: current.token })
|
|
115
|
+
const account = verifyAccount(current, payload.account)
|
|
116
|
+
return { ...payload, account, contests: Array.isArray(payload.contests) ? payload.contests : [] }
|
|
117
|
+
},
|
|
112
118
|
async sync({ limit = 200, includeProfile = true } = {}) {
|
|
113
119
|
const current = requireActive()
|
|
114
120
|
const registry = loadRegistry()
|
package/package.json
CHANGED
package/verify-shape.mjs
CHANGED
|
@@ -177,6 +177,7 @@ const cases = [
|
|
|
177
177
|
['coach_vp_import', { events: [{ eventId: 'shape-vp', contest: '测试 VP', date: today, durationMinutes: 300, problems: [{ problemId: 'A', status: 'AC', competencies: ['contest-reading'] }] }] }],
|
|
178
178
|
['coach_vp_connect', { action: 'status' }],
|
|
179
179
|
['coach_vp_sync', {}],
|
|
180
|
+
['coach_vp_contests', {}],
|
|
180
181
|
['coach_vp_replay', { contestId: 1 }],
|
|
181
182
|
['coach_postmortem', { eventId: 'shape-vp', competencies: ['contest-reading'], rootCauses: ['测试复盘'], nextActions: ['测试动作'] }],
|
|
182
183
|
['coach_focus', {}],
|