acmer-coach 2.3.1 → 2.5.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/.github/workflows/publish.yml +2 -0
- package/README.md +58 -8
- package/assets/knowledge/COMPETENCIES.yaml +39 -4
- package/assets/knowledge/CONTEST_PROFILES.yaml +61 -0
- package/assets/knowledge/NODE_SCOPES.yaml +201 -0
- package/assets/rules/coach-rules.md +54 -17
- package/dev/catalog-check.mjs +74 -0
- package/dev/coaching-contract-check.mjs +219 -0
- package/dev/curriculum-check.mjs +194 -6
- package/dev/curriculum-evidence-check.mjs +9 -2
- package/dev/parity-check.mjs +23 -5
- package/dev/run-all.sh +9 -2
- package/dev/strategy-check.mjs +179 -11
- package/dev/teaching-check.mjs +191 -0
- package/dev/vp-client-check.mjs +12 -2
- package/dev/vp-sync-check.mjs +122 -0
- package/dev/workspace-migration-check.mjs +92 -20
- package/dev.sh +8 -3
- package/docs//346/257/224/350/265/233/344/270/216/346/225/231/345/255/246/344/276/235/346/215/256.md +79 -0
- package/docs//350/256/276/350/256/241/346/226/271/351/222/210.md +23 -6
- package/docs//351/225/277/346/234/237/350/257/276/347/250/213/346/234/272/345/210/266.md +33 -6
- package/index.js +143 -83
- package/lib/curriculum-evidence.js +16 -2
- package/lib/curriculum-planning.js +75 -6
- package/lib/curriculum.js +44 -16
- package/lib/strategy.js +162 -60
- package/lib/teaching.js +78 -0
- package/lib/vp-client.js +14 -2
- package/lib/workspace-migration.js +38 -0
- package/package.json +8 -2
- package/verify-shape.mjs +35 -5
- package/verify.mjs +35 -16
package/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// acmer-coach
|
|
2
2
|
// ══════════════════════════════════════════════════════════════════
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// 小鲸教练插件。目标:保存训练位置与可核查的证据,
|
|
4
|
+
// 让教练据此决定讲法、练习和复习,避免跨会话丢进度。
|
|
5
5
|
//
|
|
6
6
|
// 注册走 bundle 层:package.json 声明 `dsh.bundle.patch`,由
|
|
7
7
|
// cordis.patch.yml 里那行 insert 挂上去。
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// · `file:` 依赖装出来的是**硬链接**,不是拷贝。原地写两边同变,但改名式
|
|
24
24
|
// 写入(Edit 工具、VS Code 原子保存)会**断开链接**,断了两边分家,
|
|
25
25
|
// 而 `dsh plugin add` 认不出分家、不重链也不报错 —— 所以改完一律跑 dev.sh。
|
|
26
|
-
import { copyFileSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, statSync
|
|
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,12 +31,14 @@ 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 { createTeachingNotes } from './lib/teaching.js'
|
|
34
35
|
import { createVpClient } from './lib/vp-client.js'
|
|
36
|
+
import { migratePersonalWorkspace as migrateWorkspacePersonalFiles } from './lib/workspace-migration.js'
|
|
35
37
|
|
|
36
38
|
const name = 'acmer-coach'
|
|
37
39
|
const inject = ['tools']
|
|
38
40
|
|
|
39
|
-
const VERSION = '2.
|
|
41
|
+
const VERSION = '2.5.0'
|
|
40
42
|
|
|
41
43
|
// 块结构(方针 §4.1.1):40 自己做 + 10 解决遗留 + 10 重写 = 60 分钟。
|
|
42
44
|
// 这是**一个题目循环**的固定形状。时间不够该换更小的题,不是把块压扁。
|
|
@@ -87,36 +89,8 @@ function createStores(vpDir) {
|
|
|
87
89
|
}
|
|
88
90
|
createStores(ACTIVE_VP_DIR)
|
|
89
91
|
|
|
90
|
-
const PERSONAL_FILES = ['PROGRESS.yaml', 'CURRICULUM.yaml', 'SCHEDULE.yaml', 'TARGET.yaml']
|
|
91
|
-
|
|
92
|
-
// 2.3.0 曾把个人文件误写进 VP 账号目录。迁移时按 mtime 取较新的一份;
|
|
93
|
-
// 被替换的主目录文件留备份,原账号目录也保留,方便人工核对和恢复。
|
|
94
92
|
function migratePersonalWorkspace(accountId) {
|
|
95
|
-
|
|
96
|
-
const sourceDir = join(DATA_DIR, 'vp-accounts', String(accountId))
|
|
97
|
-
if (!existsSync(sourceDir)) return []
|
|
98
|
-
const migrated = []
|
|
99
|
-
for (const file of PERSONAL_FILES) {
|
|
100
|
-
const from = join(sourceDir, file)
|
|
101
|
-
const to = join(DATA_DIR, file)
|
|
102
|
-
if (!existsSync(from)) continue
|
|
103
|
-
if (!existsSync(to)) {
|
|
104
|
-
copyFileSync(from, to)
|
|
105
|
-
migrated.push(file)
|
|
106
|
-
continue
|
|
107
|
-
}
|
|
108
|
-
const sourceStat = statSync(from)
|
|
109
|
-
const targetStat = statSync(to)
|
|
110
|
-
if (sourceStat.mtimeMs <= targetStat.mtimeMs) continue
|
|
111
|
-
const sourceBody = readFileSync(from)
|
|
112
|
-
const targetBody = readFileSync(to)
|
|
113
|
-
if (sourceBody.equals(targetBody)) continue
|
|
114
|
-
const backup = `${to}.pre-vp-account-${String(accountId)}`
|
|
115
|
-
if (!existsSync(backup)) copyFileSync(to, backup)
|
|
116
|
-
copyFileSync(from, to)
|
|
117
|
-
migrated.push(`${file}(更新)`)
|
|
118
|
-
}
|
|
119
|
-
return migrated
|
|
93
|
+
return migrateWorkspacePersonalFiles(DATA_DIR, accountId)
|
|
120
94
|
}
|
|
121
95
|
|
|
122
96
|
function switchCoachWorkspace(accountId = '') {
|
|
@@ -1020,7 +994,7 @@ function nodeFlags(progress, byId) {
|
|
|
1020
994
|
// 判据 = 进度文件里**有没有关于这个节点的记录**。缺省即未学(见 nodeStatus:
|
|
1021
995
|
// 只有非未学的节点才会被写进文件),所以"记录存在"本身就说明有东西发生过:
|
|
1022
996
|
// · `rec.at` —— 教练标过「在学」的那天(见 markStatus:只在状态真变时更新)
|
|
1023
|
-
// · `taught` ——
|
|
997
|
+
// · `taught` —— 旧版讲解计划留下的日期,保留兼容,不证明实际讲过
|
|
1024
998
|
// · `passes` —— 他打过卡(coach_log)
|
|
1025
999
|
// · `checks` —— 判过一次卷(coach_grade)
|
|
1026
1000
|
//
|
|
@@ -1035,9 +1009,7 @@ function taughtEvidence(progress, id) {
|
|
|
1035
1009
|
if (!rec) return null
|
|
1036
1010
|
const dates = [
|
|
1037
1011
|
String(rec.at ?? ''),
|
|
1038
|
-
//
|
|
1039
|
-
// coach_status 会一直报「还没有教学记录」,而"游标节点豁免先讲闸门"这条
|
|
1040
|
-
// 也就永远没法按"讲过没"来判断(这个漏洞就是从这儿反推出来的)。
|
|
1012
|
+
// 兼容旧版接触线索;新计划不再写 taught,续课笔记也不算掌握证据。
|
|
1041
1013
|
...(Array.isArray(rec.taught) ? rec.taught.map((t) => String(t ?? '')) : []),
|
|
1042
1014
|
...(Array.isArray(rec.passes) ? rec.passes.map((p) => String(p?.date ?? '')) : []),
|
|
1043
1015
|
...(Array.isArray(rec.checks) ? rec.checks.map((c) => String(c?.date ?? '')) : []),
|
|
@@ -1697,7 +1669,7 @@ function registerPages(ctx) {
|
|
|
1697
1669
|
// 它要防的是"规则被长对话压掉",而规则在 AGENTS.md 里 —— 那是 system prompt,
|
|
1698
1670
|
// 压根不参与压缩。真正会丢的是**位置**,而 pre-step 每轮都重注一遍,
|
|
1699
1671
|
// 压掉的下一轮就回来了。所以那半条是多余的。
|
|
1700
|
-
function registerStateHook(ctx) {
|
|
1672
|
+
function registerStateHook(ctx, teaching) {
|
|
1701
1673
|
ctx.on('agent/pre-step', async ({ messages, step }, next) => {
|
|
1702
1674
|
const decision = await next()
|
|
1703
1675
|
try {
|
|
@@ -1711,13 +1683,14 @@ function registerStateHook(ctx) {
|
|
|
1711
1683
|
const studying = Object.entries(p.nodes ?? {})
|
|
1712
1684
|
.filter(([, r]) => r?.status === 'studying')
|
|
1713
1685
|
.map(([id]) => id)
|
|
1686
|
+
const teachingContext = teaching.context(p)
|
|
1714
1687
|
|
|
1715
1688
|
// 课程可以先于游标存在;即使尚未布置动作,也要在新会话继续同一路线。
|
|
1716
1689
|
let hasCurriculum = false
|
|
1717
1690
|
try { hasCurriculum = Boolean(curriculum.load()) } catch { hasCurriculum = true }
|
|
1718
1691
|
let hasTarget = false
|
|
1719
1692
|
try { hasTarget = Boolean(strategy.loadTarget().contest) } catch { hasTarget = true }
|
|
1720
|
-
if (!cursor && !pending && !studying.length && !hasCurriculum && !hasTarget) return decision
|
|
1693
|
+
if (!cursor && !pending && !studying.length && !hasCurriculum && !hasTarget && !teachingContext) return decision
|
|
1721
1694
|
|
|
1722
1695
|
const { byId } = loadMap()
|
|
1723
1696
|
const nameOf = (id) => byId.get(String(id))?.name ?? String(id)
|
|
@@ -1725,6 +1698,7 @@ function registerStateHook(ctx) {
|
|
|
1725
1698
|
const lines = ['<coach_state>', '(教练插件注入的位置,不用再调工具问)']
|
|
1726
1699
|
lines.push(strategy.context())
|
|
1727
1700
|
lines.push(curriculum.context())
|
|
1701
|
+
if (teachingContext) lines.push(teachingContext)
|
|
1728
1702
|
lines.push(cursor
|
|
1729
1703
|
? `- 游标:${nameOf(cursor)}(${cursor})${node?.domain ? ` [${node.domain}]` : ''}`
|
|
1730
1704
|
: '- 游标:**还没设** —— 先问清他现在在哪个知识点,用 coach_set_cursor 设上')
|
|
@@ -1909,6 +1883,15 @@ const vpAccount = (value) => value && Number(value.id) > 0
|
|
|
1909
1883
|
: emptyVpAccount()
|
|
1910
1884
|
|
|
1911
1885
|
function vpEventsFromSync(payload, account) {
|
|
1886
|
+
// The platform stores timezone-less timestamps as UTC, not the client machine's local time.
|
|
1887
|
+
const instant = value => {
|
|
1888
|
+
const text = String(value || '').trim()
|
|
1889
|
+
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(text) ? `${text.replace(' ', 'T')}Z` : text
|
|
1890
|
+
const day = normalized.slice(0, 10)
|
|
1891
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(Date.parse(`${day}T12:00:00Z`)) || new Date(`${day}T12:00:00Z`).toISOString().slice(0, 10) !== day) return ''
|
|
1892
|
+
const stamp = Date.parse(normalized)
|
|
1893
|
+
return Number.isFinite(stamp) ? new Date(stamp).toISOString() : ''
|
|
1894
|
+
}
|
|
1912
1895
|
const submissions = Array.isArray(payload?.submissions) ? payload.submissions : []
|
|
1913
1896
|
const contests = Array.isArray(payload?.contests) ? payload.contests : []
|
|
1914
1897
|
const byContest = new Map()
|
|
@@ -1926,15 +1909,18 @@ function vpEventsFromSync(payload, account) {
|
|
|
1926
1909
|
return [...groups.entries()].map(([contestId, problems]) => {
|
|
1927
1910
|
const contest = byContest.get(contestId) || {}
|
|
1928
1911
|
const start = String(contest.start_time || [...problems.values()][0]?.[0]?.submitted_at || '')
|
|
1912
|
+
const startedAt = instant(start)
|
|
1929
1913
|
const normalized = [...problems.entries()].map(([problemId, attempts]) => {
|
|
1914
|
+
attempts.sort((a, b) => String(a.submitted_at || '').localeCompare(String(b.submitted_at || '')) || Number(a.id) - Number(b.id))
|
|
1930
1915
|
const solved = attempts.find((item) => ['AC', 'OK', 'ACCEPTED', 'SOLVED'].includes(String(item.verdict || '').toUpperCase()))
|
|
1931
|
-
const at = solved?.submitted_at || attempts.at(-1)?.submitted_at
|
|
1932
|
-
const minutes =
|
|
1933
|
-
return { problemId, status: solved ? 'AC' : String(attempts.at(-1)?.verdict || 'UNKNOWN').toUpperCase(), attempts: attempts.length,
|
|
1916
|
+
const at = instant(solved?.submitted_at || attempts.at(-1)?.submitted_at)
|
|
1917
|
+
const minutes = startedAt && at ? Math.max(0, Math.round((Date.parse(at) - Date.parse(startedAt)) / 60000)) : 0
|
|
1918
|
+
return { problemId, status: solved ? 'AC' : String(attempts.at(-1)?.verdict || 'UNKNOWN').toUpperCase(), attempts: attempts.length,
|
|
1919
|
+
solveMinutes: minutes, competencies: [], ...(at && Number.isFinite(Date.parse(at)) ? { occurredAt: at } : {}) }
|
|
1934
1920
|
})
|
|
1935
1921
|
return {
|
|
1936
1922
|
eventId: `vp:${account.id}:${contestId}`,
|
|
1937
|
-
contest: String(contest.title || contestId), contestId: String(contestId), date: start.slice(0, 10),
|
|
1923
|
+
contest: String(contest.title || contestId), contestId: String(contestId), date: startedAt ? ymd(new Date(startedAt)) : start.slice(0, 10),
|
|
1938
1924
|
durationMinutes: Number(contest.duration_min || 0), virtual: true, teamMode: 'team', problems: normalized,
|
|
1939
1925
|
note: '由 VP 只读同步生成;具体能力标签在复盘时补充。',
|
|
1940
1926
|
}
|
|
@@ -1966,6 +1952,43 @@ function apply(ctx) {
|
|
|
1966
1952
|
} catch { /* 没这服务 / 没这文件 → 不注,照常加载 */ }
|
|
1967
1953
|
|
|
1968
1954
|
ctx.tools.register(defineTool(curriculumTool(curriculum)))
|
|
1955
|
+
const teaching = createTeachingNotes({ loadProgress, saveProgress, loadMap,
|
|
1956
|
+
getSessionQuery: () => ctx.get?.('sessionQuery') })
|
|
1957
|
+
ctx.tools.register(defineTool({
|
|
1958
|
+
name: 'coach_note',
|
|
1959
|
+
description: '读取或保存简短续课笔记:讲到哪里、教练的判断、下次怎样接着讲。复用节点进度账本;不改掌握状态或自动决定下一课。' +
|
|
1960
|
+
'studentQuote 可选;提供时自动核对当前会话的学生原话,核对存在不代表已经理解。无回应依据时写明尚未核对,不编造表现。',
|
|
1961
|
+
parameters: {
|
|
1962
|
+
action: { type: 'string', enum: ['read', 'write'], description: '默认 read;write 追加一条笔记。' },
|
|
1963
|
+
node: { type: 'string', description: '地图节点 id。写入默认游标;读取默认最近有笔记的未完动作,其次游标,无游标时读最近笔记。显式指定始终优先。' },
|
|
1964
|
+
limit: { type: 'integer', description: '读取最近几条,默认 5,最多 20。' },
|
|
1965
|
+
summary: { type: 'string', description: 'write 必填,实际讲授位置和教练判断,最多 1000 字;区分观察与推测。' },
|
|
1966
|
+
nextStep: { type: 'string', description: 'write 必填,下次续课建议,最多 500 字;后续教练可据新表现调整。' },
|
|
1967
|
+
studentQuote: { type: 'string', description: '可选,当前会话学生实际发过的一段原话,最多 1500 字;不需要手输消息 id。' },
|
|
1968
|
+
},
|
|
1969
|
+
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
1970
|
+
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true },
|
|
1971
|
+
node: { type: 'string', required: true }, total: { type: 'integer', required: true },
|
|
1972
|
+
notes: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
1973
|
+
at: { type: 'string', required: true }, summary: { type: 'string', required: true }, nextStep: { type: 'string', required: true },
|
|
1974
|
+
studentEvidence: { type: 'object', additionalProperties: false, properties: {
|
|
1975
|
+
sessionId: { type: 'string', required: true }, seq: { type: 'integer', required: true },
|
|
1976
|
+
messageId: { type: 'string', required: true }, quote: { type: 'string', required: true },
|
|
1977
|
+
} },
|
|
1978
|
+
} } },
|
|
1979
|
+
} }, render: (_args, v) => [{ type: 'text', text: !v.ok ? `❌ ${v.reject}` : v.notes.length
|
|
1980
|
+
? `续课笔记 · ${v.node}(最近 ${v.notes.length} / 共 ${v.total} 条)\n` + v.notes.map(note =>
|
|
1981
|
+
`${note.at}\n教练记录:${note.summary}\n续课建议:${note.nextStep}\n` +
|
|
1982
|
+
(note.studentEvidence ? `学生原话:「${note.studentEvidence.quote}」(存在已核对,理解程度仍由教练判断)`
|
|
1983
|
+
: '尚无学生回应依据;这条是教练笔记。')).join('\n\n')
|
|
1984
|
+
: `「${v.node || '当前节点'}」尚无续课笔记。` }] },
|
|
1985
|
+
execute: async (args = {}, exec) => {
|
|
1986
|
+
const action = args.action ?? 'read'
|
|
1987
|
+
if (action === 'read') return teaching.read(args)
|
|
1988
|
+
if (action === 'write') return teaching.write(args, exec)
|
|
1989
|
+
return { ok: false, reject: 'action 只能是 read 或 write。', node: '', notes: [], total: 0 }
|
|
1990
|
+
},
|
|
1991
|
+
}))
|
|
1969
1992
|
|
|
1970
1993
|
// ── 目标驱动策略层:比赛目标 / VP 证据 / 复盘 / 当前重点 ─────────
|
|
1971
1994
|
// 课程图回答“有哪些知识点”,策略层回答“为什么现在学这个”。两者分开,
|
|
@@ -2003,7 +2026,7 @@ function apply(ctx) {
|
|
|
2003
2026
|
return { ok: true, reject: '', action, connected: Boolean(result.connected ?? action === 'finish'), activeAccountId: Number(result.activeAccountId || account.id || 0), account,
|
|
2004
2027
|
requestId: String(result.requestId || ''), authorizeUrl: String(result.authorizeUrl || ''), expiresAt: String(result.expiresAt || ''),
|
|
2005
2028
|
text: action === 'start' ? `打开授权页登录 VP:${result.authorizeUrl}\n授权后把页面显示的 code 交给 coach_vp_connect finish。` :
|
|
2006
|
-
action === 'finish' ? `VP 已连接:${account.handle}(账号 ${account.id}
|
|
2029
|
+
action === 'finish' ? `VP 已连接:${account.handle}(账号 ${account.id});VP 缓存按账号隔离,个人课程和进度沿用。` :
|
|
2007
2030
|
action === 'disconnect' ? 'VP 已断开;缓存保留,token 已删除。' : (result.connected ? `当前 VP:${account.handle}(账号 ${account.id})` : '当前没有已连接的 VP 账号。') }
|
|
2008
2031
|
} catch (err) {
|
|
2009
2032
|
return { ok: false, reject: String(err?.message || err), action, connected: false, activeAccountId: 0, account: emptyVpAccount(), requestId: '', authorizeUrl: '', expiresAt: '', text: '' }
|
|
@@ -2013,7 +2036,7 @@ function apply(ctx) {
|
|
|
2013
2036
|
|
|
2014
2037
|
ctx.tools.register(defineTool({
|
|
2015
2038
|
name: 'coach_vp_sync',
|
|
2016
|
-
description: '从当前 VP
|
|
2039
|
+
description: '从当前 VP 账号增量读取一页比赛、提交、事件和个人时间线。hasMore 为 true 时继续调用;pageCounts 是本页拉取数,返回数组是累计缓存。VP 平台只读,游标和缓存按账号隔离。同步后写入或更新带账号前缀的 VP 训练记录,导入被拒绝时返回 ok:false。',
|
|
2017
2040
|
parameters: {
|
|
2018
2041
|
limit: { type: 'integer', description: '单次最多同步多少条提交,默认 200' },
|
|
2019
2042
|
includeProfile: { type: 'boolean', description: '是否同步个人 rating/比赛历史,默认 true' },
|
|
@@ -2021,7 +2044,13 @@ function apply(ctx) {
|
|
|
2021
2044
|
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
2022
2045
|
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 } } },
|
|
2023
2046
|
imported: { type: 'integer', required: true }, contests: { type: 'array', required: true }, submissions: { type: 'array', required: true }, events: { type: 'array', required: true },
|
|
2024
|
-
|
|
2047
|
+
hasMore: { type: 'boolean', required: true },
|
|
2048
|
+
pageCounts: { type: 'object', required: true, additionalProperties: false, properties: {
|
|
2049
|
+
contests: { type: 'integer', required: true }, submissions: { type: 'integer', required: true }, events: { type: 'integer', required: true },
|
|
2050
|
+
} },
|
|
2051
|
+
nextCursor: { type: 'object', required: true, additionalProperties: false, properties: {
|
|
2052
|
+
submission_id: { type: 'integer', required: true }, event_id: { type: 'integer', required: true },
|
|
2053
|
+
} }, cachedPath: { type: 'string', required: true }, text: { type: 'string', required: true },
|
|
2025
2054
|
} }, render: (_a, v) => strategyText(v) },
|
|
2026
2055
|
execute: async (args) => {
|
|
2027
2056
|
try {
|
|
@@ -2029,11 +2058,17 @@ function apply(ctx) {
|
|
|
2029
2058
|
const account = vpAccount(payload.account)
|
|
2030
2059
|
switchCoachWorkspace(account.id)
|
|
2031
2060
|
const importedEvents = vpEventsFromSync(payload, account)
|
|
2032
|
-
const imported = importedEvents.length ? strategy.vpImport(importedEvents) : { imported: 0 }
|
|
2033
|
-
|
|
2034
|
-
|
|
2061
|
+
const imported = importedEvents.length ? strategy.vpImport(importedEvents, { source: 'sync' }) : { ok: true, reject: '', imported: 0 }
|
|
2062
|
+
const hasMore = payload.hasMore === true
|
|
2063
|
+
const pageCounts = payload.pageCounts
|
|
2064
|
+
return { ok: imported.ok, reject: String(imported.reject || ''), account, imported: Number(imported.imported || 0), contests: payload.contests || [], submissions: payload.submissions || [], events: payload.events || [],
|
|
2065
|
+
hasMore, pageCounts, nextCursor: { submission_id: Number(payload.cursor?.submission_id || 0), event_id: Number(payload.cursor?.event_id || 0) }, cachedPath: String(payload.cachedPath || ''),
|
|
2066
|
+
text: imported.ok
|
|
2067
|
+
? `VP 本页同步:${account.handle};本页拉取比赛 ${pageCounts.contests} 场、提交 ${pageCounts.submissions} 条、事件 ${pageCounts.events} 条;缓存累计比赛 ${payload.contests?.length || 0} 场、提交 ${payload.submissions?.length || 0} 条、事件 ${payload.events?.length || 0} 条;写入或更新训练记录 ${imported.imported || 0} 场。${hasMore ? '仍有下一页,请继续调用 coach_vp_sync。' : '分页读取完成。'}`
|
|
2068
|
+
: `VP 缓存已同步,训练记录导入被拒绝:${imported.reject}` }
|
|
2035
2069
|
} catch (err) {
|
|
2036
|
-
return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), imported: 0, contests: [], submissions: [], events: [],
|
|
2070
|
+
return { ok: false, reject: String(err?.message || err), account: emptyVpAccount(), imported: 0, contests: [], submissions: [], events: [], hasMore: false,
|
|
2071
|
+
pageCounts: { contests: 0, submissions: 0, events: 0 }, nextCursor: { submission_id: 0, event_id: 0 }, cachedPath: '', text: '' }
|
|
2037
2072
|
}
|
|
2038
2073
|
},
|
|
2039
2074
|
}))
|
|
@@ -2083,15 +2118,16 @@ function apply(ctx) {
|
|
|
2083
2118
|
|
|
2084
2119
|
ctx.tools.register(defineTool({
|
|
2085
2120
|
name: 'coach_target',
|
|
2086
|
-
description: '
|
|
2121
|
+
description: '设置或读取比赛目标;profiles 查看带来源、样本范围和限制的比赛配置,profileId 可选用。样本不是赛场预测,不自动生成奖牌线或知识点清单。目标先于知识点。',
|
|
2087
2122
|
parameters: {
|
|
2088
|
-
action: { type: 'string', required: true, enum: ['read', 'set', 'clear'] },
|
|
2123
|
+
action: { type: 'string', required: true, enum: ['read', 'profiles', 'set', 'clear'] },
|
|
2089
2124
|
expectedRevision: { type: 'integer', description: 'read 返回的 revision;首次 set 用 0。' },
|
|
2090
2125
|
contest: { type: 'string', description: '比赛名称,例如西安区域赛' },
|
|
2091
2126
|
date: { type: 'string', description: '比赛日期 YYYY-MM-DD' },
|
|
2092
2127
|
result: { type: 'string', description: '目标结果,例如区域赛金牌' },
|
|
2093
2128
|
teamMode: { type: 'string', description: 'solo 或 team' },
|
|
2094
2129
|
weeklyHours: { type: 'number', description: '每周可用训练小时' },
|
|
2130
|
+
profileId: { type: 'string', description: 'profiles 返回的配置 id;空串清除。未选择时明确缺比赛样本依据。' },
|
|
2095
2131
|
constraints: { type: 'object', additionalProperties: true, description: '可选时间约束,例如 weekday/saturday/sunday' },
|
|
2096
2132
|
priorities: { type: 'array', description: '能力优先级 [{id, weight, reason}],weight 越大越优先', items: { type: 'object', additionalProperties: false, properties: {
|
|
2097
2133
|
id: { type: 'string', required: true }, weight: { type: 'number', required: true }, reason: { type: 'string' },
|
|
@@ -2103,11 +2139,13 @@ function apply(ctx) {
|
|
|
2103
2139
|
teamMode: { type: 'string', required: true }, weeklyHours: { type: 'number', required: true }, priorities: { type: 'array', required: true },
|
|
2104
2140
|
updated: { type: 'string', required: true }, version: { type: 'integer', required: true },
|
|
2105
2141
|
constraints: { type: 'object', required: true, additionalProperties: true }, text: { type: 'string', required: true },
|
|
2142
|
+
profileId: { type: 'string', required: true }, availableProfiles: { type: 'array', required: true },
|
|
2106
2143
|
} }, render: (_a, v) => strategyText(v) },
|
|
2107
2144
|
execute: (args) => {
|
|
2108
2145
|
const v = strategy.target(args?.action || 'read', args ?? {})
|
|
2109
2146
|
return { ...v, text: v.ok
|
|
2110
|
-
?
|
|
2147
|
+
? args?.action === 'profiles' ? ['【比赛样本配置】', ...v.availableProfiles.map(p => `${p.id} · ${p.title}(${p.status})\n范围:${p.scope}\n取舍:${p.selectionGuidance.join(';')}\n限制:${p.limitations.join(';')}\n来源:${p.sources.map(s => `${s.title} ${s.url}`).join(';')}`)].join('\n\n')
|
|
2148
|
+
: `【比赛目标 · revision ${v.revision}】${v.contest} / ${v.date}\n目标:${v.result}\n模式:${v.teamMode};每周 ${v.weeklyHours} 小时\n样本配置:${v.profileId || '未选,先 profiles 核查'}\n能力重点:${v.priorities.map((p) => `${p.id}(${p.weight})`).join('、')}`
|
|
2111
2149
|
: '' }
|
|
2112
2150
|
},
|
|
2113
2151
|
}))
|
|
@@ -2116,8 +2154,21 @@ function apply(ctx) {
|
|
|
2116
2154
|
name: 'coach_vp_import',
|
|
2117
2155
|
description: '导入 VP 或模拟赛记录。它保存比赛过程证据,让教练根据读题、耗时、提交和题目选择复盘,不再只看 CF rating。重复 eventId 会更新,不会重复堆积。',
|
|
2118
2156
|
parameters: {
|
|
2119
|
-
events: { type: 'array', required: true, items: { type: 'object', additionalProperties:
|
|
2120
|
-
description: '每场含 eventId/contest/date/problems
|
|
2157
|
+
events: { type: 'array', required: true, items: { type: 'object', additionalProperties: false,
|
|
2158
|
+
description: '每场含 eventId/contest/date/problems。AC 不证明独立性;失败不直接证明能力短板。测量由核对回放后的教练明确记录,同一场同一指标仅计一次。显式导入更新整场记录,请保留已有题目与注解。', properties: {
|
|
2159
|
+
eventId: { type: 'string', required: true }, contest: { type: 'string', required: true }, date: { type: 'string', required: true },
|
|
2160
|
+
durationMinutes: { type: 'integer' }, virtual: { type: 'boolean' }, teamMode: { type: 'string', enum: ['solo', 'team'] }, note: { type: 'string' },
|
|
2161
|
+
problems: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
2162
|
+
problemId: { type: 'string', required: true }, status: { type: 'string', required: true },
|
|
2163
|
+
readMinutes: { type: 'number' }, solveMinutes: { type: 'number', description: 'VP自动数据是从开赛到AC的经过时间,不是该题净用时。' },
|
|
2164
|
+
attempts: { type: 'integer' }, competencies: { type: 'array', items: { type: 'string' } }, note: { type: 'string' },
|
|
2165
|
+
independent: { type: 'boolean' }, occurredAt: { type: 'string' },
|
|
2166
|
+
} } },
|
|
2167
|
+
measurements: { type: 'array', items: { type: 'object', additionalProperties: false, properties: {
|
|
2168
|
+
competency: { type: 'string', required: true }, metric: { type: 'string', required: true }, value: { type: 'number', required: true },
|
|
2169
|
+
passed: { type: 'boolean', required: true, description: '是否对照明确条件验收通过;不能仅凭AC推断。' }, independent: { type: 'boolean' }, occurredAt: { type: 'string' },
|
|
2170
|
+
} } },
|
|
2171
|
+
} } },
|
|
2121
2172
|
},
|
|
2122
2173
|
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
2123
2174
|
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, imported: { type: 'integer', required: true },
|
|
@@ -2132,12 +2183,17 @@ function apply(ctx) {
|
|
|
2132
2183
|
|
|
2133
2184
|
ctx.tools.register(defineTool({
|
|
2134
2185
|
name: 'coach_postmortem',
|
|
2135
|
-
description: '
|
|
2186
|
+
description: '记录 VP 复盘:findings 分 hypothesis(待确认)、confirmed(已核实短板)、resolved(已解决)。先 coach_curriculum assess 获取本场原始 VP 事实 id,结论必须引用真实 evidenceRefs。工具核对引用,不替代你核对代码/回放的因果判断;旧文字复盘只保留为观察。',
|
|
2136
2187
|
parameters: {
|
|
2137
2188
|
eventId: { type: 'string', required: true }, competencies: { type: 'array', items: { type: 'string' } },
|
|
2138
2189
|
missedReads: { type: 'array', items: { type: 'string' } }, decisionErrors: { type: 'array', items: { type: 'string' } },
|
|
2139
2190
|
teamIssues: { type: 'array', items: { type: 'string' } }, rootCauses: { type: 'array', items: { type: 'string' } },
|
|
2140
2191
|
nextActions: { type: 'array', items: { type: 'string' } }, note: { type: 'string' }, date: { type: 'string' },
|
|
2192
|
+
findings: { type: 'array', description: '每能力最多一项结论;确认前需对照代码或回放。不能把 WA 直接等同于不会。', items: { type: 'object', additionalProperties: false, properties: {
|
|
2193
|
+
competency: { type: 'string', required: true }, status: { type: 'string', required: true, enum: ['hypothesis', 'confirmed', 'resolved'] },
|
|
2194
|
+
cause: { type: 'string', required: true, description: '至少10字,说明观察与推理;不能只重复判题结果。' },
|
|
2195
|
+
evidenceRefs: { type: 'array', required: true, items: { type: 'string' }, description: '本场真实 VP 事实 id,内容变化后旧引用失效。' },
|
|
2196
|
+
} } },
|
|
2141
2197
|
},
|
|
2142
2198
|
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
2143
2199
|
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, eventId: { type: 'string', required: true },
|
|
@@ -2151,17 +2207,23 @@ function apply(ctx) {
|
|
|
2151
2207
|
|
|
2152
2208
|
ctx.tools.register(defineTool({
|
|
2153
2209
|
name: 'coach_focus',
|
|
2154
|
-
description: '
|
|
2210
|
+
description: '汇总目标、比赛样本、当前课程与近30天 VP 事实。成功不算问题;只有引用事实的 confirmed 结论增加短板计数。返回独立能力起点、待确认观察、已确认问题、验收缺口与来源。排序是取舍参考,不是掌握度;没有目标时拒绝猜方向。',
|
|
2155
2211
|
parameters: {},
|
|
2156
2212
|
output: { schema: { type: 'object', additionalProperties: false, properties: {
|
|
2157
2213
|
ok: { type: 'boolean', required: true }, reject: { type: 'string', required: true }, contest: { type: 'string', required: true },
|
|
2158
2214
|
deadline: { type: 'string', required: true }, focus: { type: 'array', required: true }, postponed: { type: 'array', required: true },
|
|
2159
2215
|
evidenceEvents: { type: 'integer', required: true }, postmortems: { type: 'integer', required: true }, text: { type: 'string', required: true },
|
|
2216
|
+
profile: { type: 'object', required: true, additionalProperties: false, properties: {
|
|
2217
|
+
id: { type: 'string', required: true }, title: { type: 'string', required: true }, scope: { type: 'string', required: true }, status: { type: 'string', required: true },
|
|
2218
|
+
sources: { type: 'array', required: true }, competencyIds: { type: 'array', required: true }, selectionGuidance: { type: 'array', required: true }, limitations: { type: 'array', required: true },
|
|
2219
|
+
} }, daysRemaining: { type: 'integer', required: true },
|
|
2160
2220
|
course: { type: 'object', required: true, additionalProperties: false, properties: {
|
|
2161
2221
|
exists: { type: 'boolean', required: true }, revision: { type: 'integer', required: true },
|
|
2162
2222
|
phaseId: { type: 'string', required: true }, phaseTitle: { type: 'string', required: true },
|
|
2163
2223
|
focusNodes: { type: 'array', required: true, items: { type: 'string' } }, pendingNodes: { type: 'array', required: true, items: { type: 'string' } },
|
|
2164
2224
|
phaseCompetencies: { type: 'array', required: true, items: { type: 'string' } },
|
|
2225
|
+
goal: { type: 'string', required: true }, outcome: { type: 'string', required: true },
|
|
2226
|
+
exitCheckResults: { type: 'array', required: true },
|
|
2165
2227
|
focusExpired: { type: 'boolean', required: true }, missingFreshEvidence: { type: 'array', required: true, items: { type: 'string' } },
|
|
2166
2228
|
} },
|
|
2167
2229
|
} }, render: (_a, v) => strategyText(v) },
|
|
@@ -2177,13 +2239,17 @@ function apply(ctx) {
|
|
|
2177
2239
|
exists: Boolean(snapshot.exists), revision: Number(snapshot.revision || 0), phaseId: String(snapshot.phaseId || ''),
|
|
2178
2240
|
phaseTitle: String(snapshot.phaseTitle || ''), focusNodes: snapshot.focusNodes ?? [], pendingNodes: snapshot.pendingNodes ?? [],
|
|
2179
2241
|
phaseCompetencies: snapshot.phaseCompetencies ?? [],
|
|
2242
|
+
goal: String(snapshot.goal || ''), outcome: String(snapshot.outcome || ''), exitCheckResults: snapshot.exitCheckResults ?? [],
|
|
2180
2243
|
focusExpired: Boolean(snapshot.focusExpired), missingFreshEvidence: snapshot.missingFreshEvidence ?? [],
|
|
2181
2244
|
}
|
|
2182
|
-
return { ...v, course, text: v.ok ? [
|
|
2183
|
-
|
|
2245
|
+
return { ...v, course, text: v.ok ? [`【训练取舍依据】长期:${course.goal || '尚未建立'}\n本次:${v.contest} · ${v.deadline} · 剩余${v.daysRemaining}天`,
|
|
2246
|
+
`样本:${v.profile.title || '未选择'}(${v.profile.status})\n适用边界:${v.profile.limitations.join(';')}`,
|
|
2247
|
+
...v.focus.map((f, i) => `${i + 1}. ${f.title}(${f.id})\n 比赛价值:${f.contestUse}\n 为什么现在:${f.whyNow}\n 关联知识点:${f.nodes.join('、') || '按比赛能力训练'}\n 近30天场次:确认短板${f.gapCount} / 独立起点${f.baselineCount} / 待确认${f.observationCount}`),
|
|
2184
2248
|
course.exists ? `课程阶段:${course.phaseTitle}(${course.phaseId})\n课程主线:${course.focusNodes.join('、') || '能力证据驱动'}\n比赛能力:${course.phaseCompetencies.join('、') || '地图知识点'}` : '课程阶段:尚未建立,用 coach_curriculum assess/read/create 建立长期路线。',
|
|
2185
2249
|
course.pendingNodes.length ? `课程未完成动作:${course.pendingNodes.join('、')}` : '',
|
|
2250
|
+
course.exitCheckResults.length ? `验收进展:${course.exitCheckResults.map(check => `${check.target}:${check.passed ? `已满足(${check.matchedEvents}次,跨${check.distinctDays}日)` : check.missing.join(',')}`).join(';')}` : '',
|
|
2186
2251
|
v.postponed.length ? `暂缓:${v.postponed.join('、')}` : '没有暂缓项。',
|
|
2252
|
+
'教练据此解释取舍并给一个动作;排序不是掌握度,也不是奖牌概率。',
|
|
2187
2253
|
].join('\n') : '' }
|
|
2188
2254
|
},
|
|
2189
2255
|
}))
|
|
@@ -2197,6 +2263,7 @@ function apply(ctx) {
|
|
|
2197
2263
|
id: { type: 'string', required: true }, title: { type: 'string', required: true }, contestUse: { type: 'string', required: true },
|
|
2198
2264
|
nodes: { type: 'array', required: true }, appliesTo: { type: 'array', required: true }, extensions: { type: 'array', required: true }, notFor: { type: 'array', required: true },
|
|
2199
2265
|
commonFailures: { type: 'array', required: true }, transferProblems: { type: 'array', required: true }, exitEvidence: { type: 'array', required: true },
|
|
2266
|
+
recognition: { type: 'array', required: true }, proofQuestions: { type: 'array', required: true }, sources: { type: 'array', required: true },
|
|
2200
2267
|
text: { type: 'string', required: true },
|
|
2201
2268
|
} }, render: (_a, v) => strategyText(v) },
|
|
2202
2269
|
execute: (args) => {
|
|
@@ -2204,6 +2271,8 @@ function apply(ctx) {
|
|
|
2204
2271
|
return { ...v, text: v.ok ? [`【范围卡片】${v.title}(${v.id})`, `比赛价值:${v.contestUse}`,
|
|
2205
2272
|
`适用:${v.appliesTo.join('、')}`, `延伸:${v.extensions.join('、')}`, `不要用于:${v.notFor.join('、')}`,
|
|
2206
2273
|
`常见错误:${v.commonFailures.join('、')}`, `迁移:${v.transferProblems.join('、')}`, `退出条件:${v.exitEvidence.join('、')}`,
|
|
2274
|
+
`识别信号:${v.recognition.join(';') || '先核对适用条件'}`, `解释与证明:${v.proofQuestions.join(';') || '先解释为何条件足够'}`,
|
|
2275
|
+
`来源:${v.sources.map(source => `${source.title} ${source.url}`).join(';') || '此卡尚无可核验来源'}`,
|
|
2207
2276
|
].join('\n') : '' }
|
|
2208
2277
|
},
|
|
2209
2278
|
}))
|
|
@@ -2716,7 +2785,7 @@ function apply(ctx) {
|
|
|
2716
2785
|
(v.suggestions.length ? `\n相近的有:${v.suggestions.join(' / ')}` : '\n也没找到相近的,确认一下写法。'),
|
|
2717
2786
|
}]
|
|
2718
2787
|
}
|
|
2719
|
-
const TIER_TAG = { core: '★核心', normal: '常见', rare: '少见', skip: '
|
|
2788
|
+
const TIER_TAG = { core: '★核心', normal: '常见', rare: '少见', skip: '默认暂缓' }
|
|
2720
2789
|
// (需求 #4):**每个节点都要出 entry**。
|
|
2721
2790
|
// 原来是 `c.entry ? ' ≈'+c.entry : ''` —— 于是「没数据」和「够不着」
|
|
2722
2791
|
// 在输出里长得一模一样(都是不显示),很容易把"没数据"读成
|
|
@@ -2959,7 +3028,7 @@ function apply(ctx) {
|
|
|
2959
3028
|
'它一次只收一个节点,所以**给不出候选列表** —— 这正是设计意图。' +
|
|
2960
3029
|
'有长期课程时先读 coach_curriculum,带 curriculumRevision;主线必须属于当前阶段,' +
|
|
2961
3030
|
'补漏或复习用 purpose 与 routeReason 说明关系和回归条件。课程节点可以跨图分支,但前置检查仍生效。' +
|
|
2962
|
-
'
|
|
3031
|
+
'比赛目标与重点排序是决策参考,不会限制在排名前三;结合学生表现说明 why。' +
|
|
2963
3032
|
'补漏/复习必须说明 routeReason。',
|
|
2964
3033
|
parameters: {
|
|
2965
3034
|
cursor: {
|
|
@@ -2982,7 +3051,8 @@ function apply(ctx) {
|
|
|
2982
3051
|
'**先讲一遍**的时间(分钟)。开一个他从没碰过的知识点时**必须给**,' +
|
|
2983
3052
|
'不给会被拒绝 —— 直接扔题让他自己卡,那是给学过的人的练法。' +
|
|
2984
3053
|
'讲多久**你自己定**(不参考任何固定值),看你判断他需要多少。' +
|
|
2985
|
-
`最低 ${TEACH_FLOOR_MIN}
|
|
3054
|
+
`最低 ${TEACH_FLOOR_MIN} 分钟,没有上限。已经在学或学过的节点不用给。` +
|
|
3055
|
+
'这是计划时长,不能证明已经讲过或学生理解了。',
|
|
2986
3056
|
},
|
|
2987
3057
|
why: { type: 'string', description: '为什么挑它(一句话,进台账,供训练员质疑时举证)' },
|
|
2988
3058
|
curriculumRevision: { type: 'integer', description: '有效课程 read 返回的 revision;无课程时可不填。' },
|
|
@@ -3132,7 +3202,7 @@ function apply(ctx) {
|
|
|
3132
3202
|
const targetRoute = strategy.route(node, { purpose: args?.purpose, courseBound: Boolean(route.binding) })
|
|
3133
3203
|
if (!targetRoute.ok) return deny(targetRoute.reject)
|
|
3134
3204
|
if (targetRoute.competencyIds.length) {
|
|
3135
|
-
why = `目标能力「${targetRoute.competencyIds.join('、')}」:${why || '
|
|
3205
|
+
why = `目标能力「${targetRoute.competencyIds.join('、')}」:${why || '目标能力对应的训练动作'}`
|
|
3136
3206
|
}
|
|
3137
3207
|
|
|
3138
3208
|
// ③ 未建课程时沿用直接后继;有课程时允许当前阶段跨分支,仍检查前置。
|
|
@@ -3240,18 +3310,7 @@ function apply(ctx) {
|
|
|
3240
3310
|
totalMinutes: net + teach + TAIL_MIN, why,
|
|
3241
3311
|
}
|
|
3242
3312
|
if (route.binding) pend[node].curriculum = route.binding
|
|
3243
|
-
//
|
|
3244
|
-
// 在节点上记一条 `taught` 日期 —— 它是「讲过没」唯一的结构化痕迹。
|
|
3245
|
-
// 在这之前讲完**不留任何记录**:coach_status 永远报「还没有教学记录」,
|
|
3246
|
-
// 游标节点那条豁免也就没法按"讲过没"来判断(漏洞就是从这儿反推出来的)。
|
|
3247
|
-
// 记的是"讲授段排上了"这一天,不是"讲得多好" —— 回答"讲过没"够用了。
|
|
3248
|
-
if (teach > 0) {
|
|
3249
|
-
const rec = { ...(progA.nodes[node] ?? {}) }
|
|
3250
|
-
const taught = Array.isArray(rec.taught) ? rec.taught.slice() : []
|
|
3251
|
-
if (!taught.includes(assignedAt)) taught.push(assignedAt)
|
|
3252
|
-
rec.taught = taught
|
|
3253
|
-
progA.nodes[node] = rec
|
|
3254
|
-
}
|
|
3313
|
+
// 布置讲解只保存计划;接触状态与理解证据由各自入口记录。
|
|
3255
3314
|
progA.pendingActions = pend
|
|
3256
3315
|
let pendingRecorded = true
|
|
3257
3316
|
try { saveProgress(progA) } catch { pendingRecorded = false }
|
|
@@ -3301,6 +3360,7 @@ function apply(ctx) {
|
|
|
3301
3360
|
// 让"讲过没"对教练**可查** —— 判据跟 coach_test 开卷时用的同一个
|
|
3302
3361
|
// 函数(taughtEvidence),不另写一份。需求 #5 的验收 ③。
|
|
3303
3362
|
cursorTaught: { type: 'string', required: true },
|
|
3363
|
+
teachingContext: { type: 'string', required: true },
|
|
3304
3364
|
domain: { type: 'string', required: true },
|
|
3305
3365
|
updated: { type: 'string', required: true },
|
|
3306
3366
|
verifiedCount: { type: 'integer', required: true },
|
|
@@ -3458,9 +3518,10 @@ function apply(ctx) {
|
|
|
3458
3518
|
// 十有八九就是游标这个节点。埋在别处等于没报。(需求 #5 验收 ③)
|
|
3459
3519
|
(v.cursorName
|
|
3460
3520
|
? (v.cursorTaught
|
|
3461
|
-
? `
|
|
3521
|
+
? ` 接触记录 ${v.cursorTaught}(不证明理解)`
|
|
3462
3522
|
: ` ⚠️ **还没有教学记录** —— 现在开卷会被拒,先讲`)
|
|
3463
3523
|
: ''),
|
|
3524
|
+
v.teachingContext,
|
|
3464
3525
|
// 押着的卷放最前面之一:它是个**卡住下一步的约束**(有新卷就开不了),
|
|
3465
3526
|
// 埋在底下等于没报。
|
|
3466
3527
|
v.pendingNode
|
|
@@ -3609,6 +3670,7 @@ function apply(ctx) {
|
|
|
3609
3670
|
cursor: p.cursor,
|
|
3610
3671
|
cursorName: byId.get(p.cursor)?.name ?? '',
|
|
3611
3672
|
cursorTaught: taughtEvidence(p, p.cursor)?.first ?? '',
|
|
3673
|
+
teachingContext: teaching.context(p),
|
|
3612
3674
|
domain: byId.get(p.cursor)?.domain ?? '',
|
|
3613
3675
|
updated: p.updated,
|
|
3614
3676
|
verifiedCount: verified.length,
|
|
@@ -5021,7 +5083,7 @@ function apply(ctx) {
|
|
|
5021
5083
|
const deny = (reject) => ({
|
|
5022
5084
|
ok: false, reject, node: '', nodeName: '', domain: '',
|
|
5023
5085
|
outcome: '', score: 0, maxScore: 0, passScore: PASS_SCORE,
|
|
5024
|
-
minutes: 0, limit: 0, status: '', verifiedAt: 0, problems: [], checks: 0,
|
|
5086
|
+
minutes: 0, limit: 0, status: '', verifiedAt: 0, selfReported: 0, problems: [], checks: 0,
|
|
5025
5087
|
})
|
|
5026
5088
|
|
|
5027
5089
|
const prog = loadProgress()
|
|
@@ -5562,10 +5624,8 @@ function apply(ctx) {
|
|
|
5562
5624
|
L.push(` ${r.nodeName} ${tag} ${ago}${rec}`)
|
|
5563
5625
|
}
|
|
5564
5626
|
L.push('',
|
|
5565
|
-
'
|
|
5566
|
-
'
|
|
5567
|
-
'做新题难受),**所以那个平衡得你来把**:复习块按上面的火候排,' +
|
|
5568
|
-
'剩下的时间留给推进。')
|
|
5627
|
+
'根据目标、剩余时间和实际表现,选择新课、复习、迁移或 VP。' +
|
|
5628
|
+
'上面的间隔只供判断;不要为了填满课表持续开新知识点。')
|
|
5569
5629
|
}
|
|
5570
5630
|
L.push('', '要排就用 coach_plan,**一次只排他说的那一天**。')
|
|
5571
5631
|
return [{ type: 'text', text: L.join('\n') }]
|
|
@@ -6066,8 +6126,8 @@ function apply(ctx) {
|
|
|
6066
6126
|
},
|
|
6067
6127
|
}))
|
|
6068
6128
|
|
|
6069
|
-
//
|
|
6070
|
-
registerStateHook(ctx)
|
|
6129
|
+
// 每个 turn 注入当前位置与续课线索,教学判断仍交给小鲸。
|
|
6130
|
+
registerStateHook(ctx, teaching)
|
|
6071
6131
|
|
|
6072
6132
|
// 技能树页面(可选注入 —— 没有 webServer 的环境跳过,工具照常工作)
|
|
6073
6133
|
ctx.inject(['webServer'], (sctx) => { registerPages(sctx) })
|
|
@@ -12,6 +12,10 @@ function date(value) {
|
|
|
12
12
|
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === d ? d : ''
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export const validOccurredAt = (value) => typeof value === 'string' &&
|
|
16
|
+
/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(value) &&
|
|
17
|
+
Boolean(date(value.slice(0, 10))) && Number.isFinite(Date.parse(value))
|
|
18
|
+
|
|
15
19
|
// Sorted object keys make references insensitive to YAML/JavaScript field order.
|
|
16
20
|
// Arrays keep their order, and the caller separately includes an event's position.
|
|
17
21
|
function canonical(value, visiting = new Set()) {
|
|
@@ -38,6 +42,7 @@ function fingerprint(record) {
|
|
|
38
42
|
|
|
39
43
|
function checkValid(record) {
|
|
40
44
|
if (!object(record) || !date(record.date) || !['failed', 'overTime', 'passed'].includes(record.outcome)) return false
|
|
45
|
+
if (record.independent !== undefined && typeof record.independent !== 'boolean') return false
|
|
41
46
|
// These fields were introduced at different times. Missing legacy fields are
|
|
42
47
|
// compatible, but present malformed numbers cannot become trusted evidence.
|
|
43
48
|
for (const key of ['score', 'maxScore', 'passScore', 'minutes', 'limit', 'verifiedAt', 'selfReported']) {
|
|
@@ -65,7 +70,7 @@ const practiceKey = (day, node, record, minutes) => JSON.stringify([
|
|
|
65
70
|
|
|
66
71
|
/**
|
|
67
72
|
* Read-only catalog of usable observations in PROGRESS and SCHEDULE.
|
|
68
|
-
*
|
|
73
|
+
* Performance fields keep their explicit types. IDs contain an event locator and a
|
|
69
74
|
* canonical content hash, so references cannot silently resolve to edited data.
|
|
70
75
|
*/
|
|
71
76
|
export function collectEvidence(progress, schedule = { days: {} }) {
|
|
@@ -73,12 +78,21 @@ export function collectEvidence(progress, schedule = { days: {} }) {
|
|
|
73
78
|
const seenIds = new Set()
|
|
74
79
|
const progressPractice = new Set()
|
|
75
80
|
const add = (source, node, day, locator, eventId, signal, summary, record) => {
|
|
81
|
+
if (record.occurredAt !== undefined && !validOccurredAt(record.occurredAt)) return false
|
|
76
82
|
const hash = fingerprint(record)
|
|
77
83
|
if (!hash) return false
|
|
78
84
|
const id = `${source}:${encodeURIComponent(node)}:${locator}:${hash}`
|
|
79
85
|
if (!seenIds.has(id)) {
|
|
80
86
|
seenIds.add(id)
|
|
81
|
-
|
|
87
|
+
const facts = {}
|
|
88
|
+
if (typeof record.independent === 'boolean') facts.independent = record.independent
|
|
89
|
+
if (record.occurredAt !== undefined) facts.occurredAt = record.occurredAt
|
|
90
|
+
const minutes = source === 'schedule' ? record.actualMin : record.minutes
|
|
91
|
+
if (positive(minutes)) facts.minutes = minutes
|
|
92
|
+
if (typeof record.metric === 'string' && record.metric.trim() && Number.isFinite(record.value)) {
|
|
93
|
+
facts.metric = record.metric.trim(); facts.value = record.value
|
|
94
|
+
}
|
|
95
|
+
result.push({ id, eventId, source, node, date: day, signal, summary, fingerprint: hash, ...facts })
|
|
82
96
|
}
|
|
83
97
|
return true
|
|
84
98
|
}
|