diff --git a/README.md b/README.md
index 8d006ec..8b9e8b4 100644
--- a/README.md
+++ b/README.md
@@ -100,6 +100,8 @@ KataGo 是事实裁判,LLM 是讲棋老师。GoAgent 的目标是让学生不
### 多模态讲棋
+**为了获得最佳讲棋效果,建议使用支持图片输入的多模态模型。** 当前讲棋流程会核验棋盘图片,并结合精确棋谱坐标与 KataGo 证据解释局面;图片中的观察不能覆盖棋谱事实。
+
当前手分析会把这些信息组合给用户配置的多模态 LLM:
- 当前棋盘截图。
diff --git a/README_EN.md b/README_EN.md
index 55d630c..dd005c1 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -153,6 +153,8 @@ pnpm dist:linux
## AI Teacher Connections
+**For the best teaching results, we recommend a multimodal model that accepts images.** The current teaching workflow validates board images alongside exact SGF coordinates and KataGo evidence; visual observations must not override the game record.
+
- **API key**: Continue using any OpenAI-compatible multimodal model service.
- **ChatGPT sign-in**: Choose “Sign in with ChatGPT” under **Settings → AI Teacher**. GoAgent uses the official Codex App Server for sign-in, model discovery, and requests, and can use models in the active ChatGPT plan that accept board images.
- ChatGPT sign-in uses a GoAgent-specific Codex App Server data directory. It does not read or modify Codex CLI or Codex Desktop sign-in. GoAgent application code does not read, copy, or print OAuth tokens.
diff --git a/docs/COMMENTARY_CONCEPT_EXPERIMENTS.md b/docs/COMMENTARY_CONCEPT_EXPERIMENTS.md
new file mode 100644
index 0000000..c42678e
--- /dev/null
+++ b/docs/COMMENTARY_CONCEPT_EXPERIMENTS.md
@@ -0,0 +1,88 @@
+# 围棋解说机制与概念识别实验
+
+本次改动将精确棋谱、可验证的棋形与棋块关系、同局面 KataGo 比较和简短输出策略接入实际老师流程。目标是减少错误的棋理断言,并让当前手讲解直接表达主要目的。
+
+**为了获得最佳讲棋效果,建议使用支持图片输入的多模态模型。** 当前流程会核验棋盘图片,并将精确坐标和引擎证据交给模型。结构化证据本身不依赖视觉识别,但当前程序尚未提供经过验证的纯文本讲棋路径;本轮也未进行相同输入条件下有图与无图的隔离对照,因此这一建议不是多模态收益的定量结论。
+
+## 讲棋机制
+
+### 棋形、目的与结果分层
+
+- 相对尖、跳、飞、直接接触、空走廊及阻挡属于可核对的棋盘事实;保留多个己棋锚点与真实颜色。
+- 争头、限制出头、分割联系、己方出头及沿边展开属于带目标与反证的目的假设。
+- 已经封锁、已经分断、死活、先手与强制应手需要独立证据;不能从几何、局部模式分数、气数或 ownership 自动推出。
+- 本地知识检索移除全盘绝对坐标巧合及 PV 锚点加分。题型相似只提示应检查什么,不授权将题库答案当作当前局面的合法变化。
+
+### 棋块关系与实际落子效果
+
+`boardGroupRelations.ts` 提供完整黑白坐标、真实正交连通串、潜在联系及区域目的候选。潜在联系不等于已经连接,正交串不等于战略整龙。
+
+`ownDevelopment.ts` 比较己方受压方向、开放走廊与前端锚点,帮助区分自身出头和攻击对方。同一己串有跳与背侧飞关系时,不能任选一个锚点给整手命名。自身发展候选也不证明棋块未活或已经安定。
+
+`moveBoardEffects.ts` 模拟落子、提子、连接和气的变化。局部单子劫循环以结构证据描述;反事实回提只检验棋盘结构,不验证历史劫禁或超级劫。粘劫、收官和连接不能自动称作弱棋补强。
+
+### 全局价值与具体着手评价
+
+`globalPriorityEvidence.ts` 比较同一落子前局面的首选与实战,区分普通根候选和单独 forced 实战查询,按行棋方统一视角。不能用落子前后 root 估值相减替代同局面选择损失。
+
+远处阈值随棋盘尺寸变化;明显差距采用至少3目或非饱和胜率区间内8个百分点,并核验根搜索深度、候选搜索次数与教学就绪状态。单独 forced 实战查询次数不能抬高普通根搜索质量。
+
+本地存在接近首选的替代点时,不把具体实战点的损失归因于整个局部不重要。具体实战点有可靠显著损失时,即使首选不满足远处条件,也应简短指出更好的选择及已核验用途。
+
+### 证据传递与简短输出
+
+- 修复 KataGo 顶层 root ownership 读取,兼容旧 remote nested 输出并严格检查尺寸与数值范围。
+- 核对棋局、手数、落子前时点和试下分支。根候选使用同一落子前棋盘,PV 后续必须另行重放。
+- 显式要求加深时核对实际根搜索预算,防止浅层 prefetched 分析覆盖加深请求;试下使用实际试下分析入口。
+- 完整坐标、全局比较、紧凑候选发展与关键落子效果优先序列化,减少工具消息18k字符预算截断导致的事实遗漏。
+- 保留合法 assistant tool_calls、对应工具成功/失败结果和后续图片。Codex 自动工具当前手路径在首次模型请求前准备真实棋盘图,减少动态图片传递的不可靠情况。
+- 默认当前手解说一句话、约20–60中文字,选择主要有据目的。小差距省略推荐比较,内部核验深度不要求最终长篇。明确详解或变化请求仍可展开。
+
+## 实验路线与选择
+
+| 路线 | 实际实验 | 结论 |
+| --- | --- | --- |
+| 相对几何、关系与 KataGo 证据 | 真实规则执行、棋盘模拟及老师工具集成 | 已接入本次候选实现,仍需围棋语义审阅 |
+| 棋盘线性模型 / 小 CNN | 6000条评论局面,按完整棋谱组隔离训练、验证和测试 | 评论词语弱标签精确率不足,未接入产品 |
+| 棋盘 + KataGo ownership/policy | 同600局面配对探索 | 增益不稳定,未接入产品 |
+| 隐藏层 probe | 核验论文、原代码与普通 KataGo 接口 | 未训练;需要适配引擎和专家标签 |
+
+数据准备默认固定源提交并下载1000盘 SGF,获得32,760个合法主线评论局面。评论关键词不是当前手意图金标,包含假设变化、否定与省略等噪声;实验输入也不等同于产品的落子前目的解释。
+
+平衡 CNN 探索的宏精确率约0.0403、宏 F1 约0.0722,不能因能输出术语就用于产品命名。同600局面探索中,加入 KataGo 的 CNN F1 从0.0533到0.0633,但按棋谱组 bootstrap 的差异区间跨0,不能认定稳定胜出。完整数据、模型权重及原评论不随源码或发行包分发。
+
+来源与许可:
+
+- [ACL 2022论文](https://aclanthology.org/2022.acl-short.90/)、[原始数据代码](https://github.com/AndreHe02/go)、[原 probe 代码](https://github.com/AndreHe02/go-probe)。
+- [GTL版权说明](https://gtl.xmp.net/about/site):原评论属于评审者,本次仅作私人研究。
+- [KataGo Analysis API](https://github.com/lightvector/KataGo/blob/master/docs/Analysis_Engine.md)、[模型许可](https://katagotraining.org/network_license/)。
+- [MasterMind](https://github.com/opendilab/Mastermind) 的学术用途及书籍来源限制未澄清,本次未使用其数据训练。
+
+## 验证与人工审阅
+
+改动已通过392项项目测试、类型检查和生产构建。覆盖颜色/对称/阻挡反例、占据与缺盘弃权、坐标完整性、同根比较、当前方视角、浅搜索保护、试下加深、工具历史、初始图片和实际棋盘效果。工程测试通过不代表解说语义完全正确;打包发行尚未验证。
+
+真实老师评测使用用户选择的 ChatGPT GPT-6 Luna,匿名 SGF 只包含尺寸、贴目、规则、初始棋子和完整真实落子历史;不传入源评论、专家术语或反馈。运行记录核验实际加载构建、模型/provider、棋谱、提示、设置、棋盘和图片身份,保存原文、失败及各次运行;provider temperature/effort 使用默认值。缺失棋谱规则时明确记录日本规则 fallback。
+
+人工审阅发现并推动了全局重点、棋块目的、简短表达及己方出头等修正。新增随机样本先固定抽样再生成,不依据模型结果换样。反馈用于修改后,相应局面是开发回归样本,不能用于独立总体准确率统计。
+
+最后三局回归原文已转为自身发展、粘劫及跳出,并能解释推荐连接点;用户认为可接受。仍有次要推断和泛化提醒可省略,尚未实现任意自然语言颜色/占据/战术断言的全面语义核验。三局运行流程完成不等于泛化准确率。
+
+审阅工具显示完整实际原文与落子前后棋盘。填写反馈自动暂存在浏览器,点击“保存反馈到项目”才写入项目;浏览器保存网页或下载 JSON 备份不等于提交。工具默认只监听 loopback。源码不包含本机评测产物、用户原始反馈或认证数据。
+
+## 复现入口
+
+```sh
+pnpm test
+pnpm typecheck
+pnpm build
+python -m pip install -r scripts/experiments/requirements-comment-concepts.txt
+python scripts/experiments/prepare_comment_concepts.py --games 1000
+python scripts/experiments/train_comment_concepts.py --data .tmp/concept-data/comments.jsonl --out .tmp/concept-experiments/board6000 --max-samples 6000 --epochs 8
+pnpm eval:move-concepts
+python scripts/serve_concept_review.py --directory .tmp/concept-review --port 8847
+```
+
+引擎实验需要自行提供 KataGo binary、model 与配置路径,不下载或提交本机二进制。`collect_katago_concepts.py --help` 与 `collect_review_analysis.py --help` 列出参数。
+
+真实老师、盲评与短句预览入口为 `eval_commentary_pipeline.mjs`、`build_commentary_blind_review.mjs` 和 `build_commentary_short_preview.mjs`。实际评测需要运行配置好模型、KataGo 与 remote debugging 的隔离 GoAgent 实例及匿名 fixtures;查看各脚本 `--help`。筛选参数为 `--onlyids`。生产程序不依赖这些评测脚本。
diff --git a/package.json b/package.json
index ff485f4..a065fff 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
"preview": "node scripts/start_dev_app.mjs --skip-build",
"lint": "pnpm typecheck",
- "test": "node --test tests/*.test.mjs",
+ "test": "node --import tsx --test tests/*.test.mjs",
"check": "pnpm test && pnpm typecheck && pnpm build",
"eval:teacher": "node scripts/eval_teaching_accuracy.mjs",
"eval:claims": "node scripts/eval_teacher_claims.mjs",
@@ -30,6 +30,7 @@
"check:knowledge-sources": "node scripts/check_knowledge_sources.mjs",
"eval:knowledge-coverage": "node scripts/eval_knowledge_coverage.mjs",
"eval:shape-recognition": "node scripts/eval_shape_recognition.mjs",
+ "eval:move-concepts": "node scripts/eval_move_concepts.mjs",
"eval:move-range": "node scripts/eval_move_range.mjs",
"eval:vision-evidence": "node scripts/eval_vision_evidence.mjs",
"eval:katago-trace": "node scripts/eval_katago_trace.mjs",
diff --git a/scripts/build_commentary_blind_review.mjs b/scripts/build_commentary_blind_review.mjs
new file mode 100644
index 0000000..02faac7
--- /dev/null
+++ b/scripts/build_commentary_blind_review.mjs
@@ -0,0 +1,267 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict'
+import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs'
+import { resolve, join, relative, dirname, isAbsolute } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { createHash, randomInt } from 'node:crypto'
+
+const root = fileURLToPath(new URL('../', import.meta.url))
+const hash = value => createHash('sha256').update(value).digest('hex')
+const read = path => JSON.parse(readFileSync(path, 'utf8'))
+const canonical = value => JSON.stringify(value, (_, v) => v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map(k => [k, v[k]])) : v)
+const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
+const columns = 'ABCDEFGHJKLMNOPQRSTUVWXYZ'
+const within = (parent, child) => { const path = relative(parent, child); return path !== '' && !path.startsWith('..') && !isAbsolute(path) }
+
+function secureOutput(output, inputs) {
+ const temporary = resolve(root, '.tmp')
+ assert.ok(within(temporary, output), 'Output must be a child directory of this repository .tmp, not .tmp itself')
+ for (const input of inputs) assert.ok(output !== input && !within(output, input) && !within(input, output), 'Output must be separate from input directories')
+ let ancestor = output
+ while (!existsSync(ancestor)) ancestor = dirname(ancestor)
+ assert.ok(within(realpathSync(temporary), realpathSync(ancestor)) || realpathSync(temporary) === realpathSync(ancestor), 'Output ancestor escapes .tmp through a link')
+ if (existsSync(output)) assert.equal(realpathSync(output).toLowerCase(), output.toLowerCase(), 'Linked output directories are unsupported')
+}
+
+function point(raw, size) {
+ if (/^pass$/i.test(raw)) return null
+ const match = /^([A-HJ-Z])([1-9]\d?)$/.exec(raw)
+ assert.ok(match, 'Invalid board point')
+ const x = columns.indexOf(match[1]); const y = Number(match[2]) - 1
+ assert.ok(x < size && y < size, 'Point outside board')
+ return [x, y]
+}
+const key = (x, y) => `${x},${y}`
+function neighbors(x, y, size) { return [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]].filter(([a, b]) => a >= 0 && b >= 0 && a < size && b < size) }
+function group(board, origin, size) {
+ const color = board.get(origin); const stones = new Set([origin]); const pending = [origin]; const liberties = new Set()
+ while (pending.length) {
+ const [x, y] = pending.pop().split(',').map(Number)
+ for (const [a, b] of neighbors(x, y, size)) {
+ const p = key(a, b)
+ if (!board.has(p)) liberties.add(p)
+ else if (board.get(p) === color && !stones.has(p)) { stones.add(p); pending.push(p) }
+ }
+ }
+ return { stones, liberties }
+}
+function play(board, color, coordinate, size) {
+ if (!coordinate) return
+ const [x, y] = coordinate; const p = key(x, y)
+ assert.ok(!board.has(p), 'Replay found occupied move')
+ board.set(p, color)
+ for (const [a, b] of neighbors(x, y, size)) {
+ const q = key(a, b)
+ if (board.has(q) && board.get(q) !== color) {
+ const enemy = group(board, q, size)
+ if (!enemy.liberties.size) for (const stone of enemy.stones) board.delete(stone)
+ }
+ }
+ assert.ok(group(board, p, size).liberties.size, 'Replay found suicide; unsupported fixture rules')
+}
+function boardList(board) {
+ return [...board].map(([p, color]) => { const [x, y] = p.split(',').map(Number); return { point: `${columns[x]}${y + 1}`, color } }).sort((a, b) => a.point.localeCompare(b.point))
+}
+
+export function fixtureBoards(fixture, source, sgf) {
+ const size = fixture.boardSize
+ assert.equal(Number(/SZ\[(\d+)\]/.exec(sgf)?.[1]), size)
+ assert.equal(hash(sgf.replace(/\r?\n$/, '')), fixture.sha256, 'Fixture content hash changed')
+ assert.ok([...sgf.matchAll(/([A-Z]+)\[/g)].every(match => ['SZ', 'KM', 'RU', 'AB', 'AW', 'B', 'W'].includes(match[1])), 'Nonanonymous SGF metadata')
+ const decode = value => value ? [value.charCodeAt(0) - 97, size - 1 - (value.charCodeAt(1) - 97)] : null
+ const board = new Map()
+ for (const setup of sgf.matchAll(/A([BW])((?:\[[a-y]{2}\])+)/g)) for (const value of setup[2].matchAll(/\[([a-y]{2})\]/g)) {
+ const [x, y] = decode(value[1]); assert.ok(x < size && y >= 0, 'Invalid setup stone')
+ assert.ok(!board.has(key(x, y)), 'Duplicate setup stone'); board.set(key(x, y), setup[1])
+ }
+ const moves = [...sgf.matchAll(/;([BW])\[([a-y]{2}|)\]/g)].map(match => [match[1], decode(match[2])])
+ assert.equal(moves.length, fixture.moveNumber, 'SGF/history mismatch')
+ assert.equal(source.moveNumber, fixture.moveNumber)
+ for (const [color, coordinate] of moves.slice(0, -1)) play(board, color, coordinate, size)
+ const before = boardList(board)
+ assert.ok(Array.isArray(source.boardSnapshot), 'Source has no complete snapshot')
+ const snapshot = new Map()
+ for (const stone of source.boardSnapshot) {
+ assert.ok(['B', 'W'].includes(stone.color)); const [x, y] = point(stone.point, size)
+ assert.ok(!snapshot.has(key(x, y)), 'Duplicate snapshot stone'); snapshot.set(key(x, y), stone.color)
+ }
+ assert.equal(canonical(before), canonical(boardList(snapshot)), 'SGF replay does not equal source before-move snapshot')
+ assert.equal(moves.at(-1)?.[0], source.playerColor)
+ assert.equal(canonical(moves.at(-1)?.[1]), canonical(point(source.playedMove, size)), 'Played move differs from source')
+ play(board, source.playerColor, moves.at(-1)[1], size)
+ const recentMoves = moves.slice(0, -1).slice(-8).map(([color, coordinate], index, list) => ({
+ moveNumber: moves.length - list.length + index, color,
+ point: coordinate ? `${columns[coordinate[0]]}${coordinate[1] + 1}` : 'pass'
+ }))
+ return { boardSize: size, moveNumber: fixture.moveNumber, playerColor: source.playerColor, playedMove: source.playedMove, before, after: boardList(board), recentMoves }
+}
+
+export function validatePair(fixture, candidate, baseline) {
+ for (const [name, result] of [['candidate', candidate], ['baseline', baseline]]) {
+ assert.equal(result?.success, true, 'Both real results must be successful')
+ assert.equal(result.phase, 'formal', 'Only formal evaluations can enter blind review')
+ assert.equal(result.variant, name, 'Input result is in the wrong directory')
+ assert.equal(result.id, fixture.id)
+ assert.equal(result.model, 'gpt-6-luna')
+ assert.equal(result.provider, 'codex-app-server', 'Mock and other providers are excluded')
+ assert.ok(result.result?.markdown?.trim(), 'Missing complete model commentary')
+ assert.equal(result.identity?.fixtureHash, fixture.sha256)
+ assert.equal(result.identity?.model, result.model)
+ assert.equal(result.identity?.provider, result.provider)
+ assert.equal(result.identity?.variant, name)
+ assert.equal(result.identity?.phase, 'formal')
+ assert.match(result.profileHash ?? '', /^[a-f0-9]{64}$/, 'Missing semantic student profile hash')
+ for (const field of ['bundleHash', 'runtimeHash', 'engineConfigHash']) {
+ assert.match(result[field] ?? '', /^[a-f0-9]{64}$/, `Missing ${field}`)
+ assert.equal(result.identity[field], result[field])
+ }
+ for (const field of ['promptHash', 'settingsHash']) assert.match(result.identity[field] ?? '', /^[a-f0-9]{64}$/)
+ assert.equal(result.request?.moveNumber, fixture.moveNumber)
+ assert.equal(result.request?.mode, 'current-move')
+ assert.equal(hash(result.request.prompt), result.identity.promptHash)
+ assert.equal(hash(JSON.stringify({ app: result.applicationSettings, request: result.requestSettings })), result.identity.settingsHash)
+ assert.ok(result.requestSettings && result.applicationSettings && result.inferenceSettings, 'Missing reproducible settings')
+ assert.equal(result.analysis?.moveNumber, fixture.moveNumber, 'Analysis move number mismatch')
+ assert.equal(result.analysis?.boardSize, fixture.boardSize, 'Analysis board size mismatch')
+ assert.equal(result.analysis?.gameId, result.request.gameId, 'Analysis belongs to another game')
+ assert.equal(result.boardVerification?.verified, true, 'Missing imported-board verification')
+ for (const phase of ['before', 'after']) assert.equal(result.boardVerification[phase]?.length, fixture.boardSize ** 2, 'Incomplete verified board')
+ }
+ assert.equal(candidate.profileHash, baseline.profileHash, 'Semantic student profile changed between paired runs')
+ for (const phase of ['before', 'after']) assert.equal(canonical(candidate.boardVerification[phase]), canonical(baseline.boardVerification[phase]), 'Verified boards differ between paired runs')
+ for (const field of ['fixtureHash', 'runtimeHash', 'engineConfigHash', 'promptHash', 'settingsHash']) assert.equal(candidate.identity[field], baseline.identity[field], `Pair ${field} mismatch`)
+ for (const field of ['requestSettings', 'applicationSettings', 'inferenceSettings']) assert.equal(canonical(candidate[field]), canonical(baseline[field]), `Pair ${field} mismatch`)
+ const cleanRequest = request => Object.fromEntries(Object.entries(request).filter(([name]) => name !== 'runId'))
+ assert.equal(canonical(cleanRequest(candidate.request)), canonical(cleanRequest(baseline.request)), 'Pair request mismatch')
+}
+
+function verifyReplayEvidence(boards, result) {
+ for (const phase of ['before', 'after']) {
+ const flat = Array(boards.boardSize ** 2).fill(0)
+ for (const stone of boards[phase]) { const [x, y] = point(stone.point, boards.boardSize); flat[y * boards.boardSize + x] = stone.color === 'B' ? 1 : -1 }
+ assert.equal(canonical(flat), canonical(result.boardVerification[phase]), 'Verified result board differs from anonymous SGF replay')
+ }
+}
+
+export function runStatus(result) {
+ if (!result || result.phase !== 'formal') return { attempted: false, passed: false, text: '尚无正式运行记录' }
+ if (result.model !== 'gpt-6-luna' || result.provider !== 'codex-app-server') return { attempted: false, passed: false, text: '记录不符合本次评测条件' }
+ if (result.success === true) return { attempted: true, passed: true, text: '整链验收已通过' }
+ if (result.result?.markdown?.trim()) return { attempted: true, passed: false, text: /evaluator interruption|Recovery incomplete|评测中断/i.test(result.error ?? '') ? '讲解已返回,但评测中断、验收证据不完整' : '讲解已返回,但整链验收未通过' }
+ return { attempted: true, passed: false, text: '运行或整链验收未通过' }
+}
+
+function statusPanel(data) {
+ const summary = data.summary ?? { plannedPositions: data.cases.length / 2, completePairs: data.cases.length / 2, unpairedPositions: 0, expectedRuns: data.cases.length, passedRuns: data.cases.length, unpassedRuns: 0, pendingRuns: 0 }
+ const failures = (data.positions ?? []).filter(item => item.state !== 'complete').map(item => `
${escape(item.id)}:${item.statuses.map(escape).join(';')}。${escape(item.state === 'incompatible' ? '配对条件或棋盘核对未通过' : '缺少完整合格配对')}。`).join('')
+ return `本轮覆盖情况
预定 ${summary.plannedPositions} 局 · 完整配对 ${summary.completePairs} 局 · 未配对 ${summary.unpairedPositions} 局。
两组运行合计 ${summary.expectedRuns} 份:整链验收通过 ${summary.passedRuns} 份,未通过或评测中断 ${summary.unpassedRuns} 份,尚无合格正式记录 ${summary.pendingRuns} 份。
下面只评完整配对的讲解。未配对局不计语义准确率,排除失败也不能说明讲解更准确。${summary.completePairs === 0 ? '目前没有完整配对,流水线尚未完成。' : ''}
${failures ? `未配对局状态(不区分两组)
` : ''}`
+}
+
+function boardSvg(entry, stones, phase) {
+ const step = 22; const offset = 32; const end = offset + (entry.boardSize - 1) * step; const extent = end + 32
+ let svg = `'
+}
+
+export function reviewHtml(publicData) {
+ const options = values => `${values.map(v => ``).join('')}`
+ const entries = new Map(publicData.cases.map(entry => [entry.id, entry]))
+ const ids = publicData.cases.filter(entry => entry.id.endsWith(':A')).map(entry => entry.id.slice(0, -2))
+ const cards = statusPanel(publicData) + ids.map((id, index) => {
+ const entry = entries.get(`${id}:A`)
+ const panels = ['A', 'B'].map(letter => { const result = entries.get(`${id}:${letter}`); return `` }).join('')
+ const recent = (entry.recentMoves ?? []).map(move => `第 ${move.moveNumber} 手 ${move.color === 'B' ? '黑' : '白'} ${move.point === 'pass' ? '停着' : move.point}`).join(' → ')
+ return `${index + 1}. ${id} · 第 ${entry.moveNumber} 手 · ${entry.playerColor === 'B' ? '黑' : '白'} ${entry.playedMove}
${boardSvg(entry, entry.before, 'before')}
${boardSvg(entry, entry.after, 'after')}
红圈标记本手;停着无落点圈。
请分别评 A、B 两段完整讲解。可以展开或收起原文,棋盘可切换到落子后查看提子结果。
落子前最近手顺
${escape(recent || '没有此前着手。')}
${panels}
`
+ }).join('')
+ const storageKey = `goagent-commentary-blind-v1-${publicData.reviewSetId}`
+ return `围棋讲解盲评围棋讲解盲评
模型:${publicData.model} · ${ids.length} 局,两段讲解均来自同一模型。
建议先看还没审过的局面。先对照棋盘判断事实,再看讲解是否解释清楚关系、目的与风险;不必因为术语多就给高分。不确定时可直接标“不确定”。
${cards}`
+}
+
+export function main(args = process.argv.slice(2)) {
+ const option = (name, fallback) => { const at = args.indexOf(`--${name}`); if (at < 0) return fallback; assert.ok(args[at + 1] && !args[at + 1].startsWith('--'), `Missing --${name} value`); return args[at + 1] }
+ if (args.includes('--help')) { console.log('node scripts/build_commentary_blind_review.mjs [--input .tmp/commentary-live-v2] [--cases .tmp/concept-review-round2/cases.json] [--out .tmp/commentary-blind-review-v2] [--onlyids position-1,position-2]'); return }
+ const input = resolve(root, option('input', '.tmp/commentary-live-v2'))
+ const output = resolve(root, option('out', '.tmp/commentary-blind-review-v2'))
+ const sourcePath = resolve(root, option('cases', '.tmp/concept-review-round2/cases.json'))
+ const privateDirectory = resolve(root, '.tmp/.commentary-blind-private')
+ secureOutput(output, [input, dirname(sourcePath), privateDirectory])
+ secureOutput(privateDirectory, [input, output, dirname(sourcePath)])
+ const mappingPath = join(privateDirectory, `${hash(output.toLowerCase()).slice(0, 24)}.json`)
+ const mapping = existsSync(mappingPath) ? read(mappingPath) : { schemaVersion: 1, reviewSetId: hash(output.toLowerCase()).slice(0, 24), pairs: {} }
+ assert.equal(mapping.schemaVersion, 1)
+ const manifest = read(join(input, 'fixtures/manifest.json'))
+ const source = new Map(read(sourcePath).cases.map(entry => [entry.id, entry]))
+ const requested = option('onlyids', '').split(',').filter(Boolean)
+ assert.ok(requested.every(id => /^position-[1-9]\d*$/.test(id) && manifest.cases.some(item => item.id === id)), 'Unknown anonymous position ID')
+ assert.equal(new Set(requested).size, requested.length, 'Duplicate position IDs')
+ const selected = requested.length ? requested.map(id => manifest.cases.find(item => item.id === id)) : manifest.cases
+ const cases = []; const rejected = []; const positions = []
+ let passedRuns = 0; let attemptedRuns = 0
+ const artifact = path => { try { return read(path) } catch { return null } }
+ for (const fixture of selected) {
+ const candidate = artifact(join(input, 'candidate', `${fixture.id}.json`)); const baseline = artifact(join(input, 'baseline', `${fixture.id}.json`))
+ const statuses = [runStatus(candidate), runStatus(baseline)]
+ passedRuns += statuses.filter(status => status.passed).length
+ attemptedRuns += statuses.filter(status => status.attempted).length
+ const positionStatus = { id: fixture.id, state: 'unpaired', statuses: statuses.map(status => status.text).sort() }
+ positions.push(positionStatus)
+ try {
+ assert.match(fixture.id, /^position-[1-9]\d*$/)
+ validatePair(fixture, candidate, baseline)
+ const original = source.get(fixture.localCaseId); assert.ok(original, 'Missing source snapshot')
+ assert.ok(within(join(input, 'fixtures'), resolve(fixture.filePath)), 'Fixture outside input fixture directory')
+ const boards = fixtureBoards(fixture, original, readFileSync(fixture.filePath, 'utf8'))
+ verifyReplayEvidence(boards, candidate); verifyReplayEvidence(boards, baseline)
+ const fingerprint = hash(canonical({ fixtureHash: fixture.sha256, candidate: candidate.identity, baseline: baseline.identity, profiles: [candidate.profileHash, baseline.profileHash], texts: [candidate.result.markdown, baseline.result.markdown], boards }))
+ const prior = mapping.pairs[fixture.id]
+ assert.ok(!prior || prior.fingerprint === fingerprint, 'Pair changed after assignment; use a separate --out directory')
+ const a = prior?.a ?? (randomInt(2) ? 'candidate' : 'baseline')
+ assert.ok(['candidate', 'baseline'].includes(a))
+ mapping.pairs[fixture.id] = { a, fingerprint }
+ const texts = a === 'candidate' ? [candidate.result.markdown, baseline.result.markdown] : [baseline.result.markdown, candidate.result.markdown]
+ for (const [index, letter] of ['A', 'B'].entries()) cases.push({ id: `${fixture.id}:${letter}`, ...boards, markdown: texts[index], review: { geometryCorrect: '', useful: '', intendedTerm: '', notes: '' } })
+ positionStatus.state = 'complete'
+ } catch (error) {
+ if (statuses.every(status => status.passed)) positionStatus.state = 'incompatible'
+ rejected.push({ id: fixture.id, reason: error.message })
+ }
+ }
+ assert.ok(attemptedRuns > 0, 'No formal real records; no review output generated')
+ if (existsSync(join(output, 'feedback.json'))) {
+ const existing = read(join(output, 'feedback.json'))
+ assert.ok(existing.cases.every(entry => cases.some(item => item.id === entry.id)), 'Existing feedback includes omitted pairs; select them too or use a new output directory')
+ }
+ const summary = { plannedPositions: selected.length, completePairs: cases.length / 2, unpairedPositions: selected.length - cases.length / 2,
+ expectedRuns: selected.length * 2, passedRuns, unpassedRuns: attemptedRuns - passedRuns, pendingRuns: selected.length * 2 - attemptedRuns }
+ const publicData = { schemaVersion: 1, model: 'gpt-6-luna', reviewSetId: mapping.reviewSetId, summary, positions, cases }
+ const json = JSON.stringify(publicData, null, 2)
+ for (const id of source.keys()) assert.ok(!json.includes(id), 'Original case identifier in model commentary; cannot publish anonymously')
+ assert.ok(!/"(?:variant|localCaseId|labels|weakMentions|feedback|comment)"\s*:/.test(json), 'Private metadata in public cases')
+ const html = reviewHtml(publicData)
+ mkdirSync(privateDirectory, { recursive: true }); writeFileSync(mappingPath, JSON.stringify(mapping, null, 2) + '\n')
+ mkdirSync(output, { recursive: true })
+ for (const name of ['cases.json', 'review.html']) assert.ok(!existsSync(join(output, name)) || realpathSync(join(output, name)).toLowerCase() === join(output, name).toLowerCase(), 'Linked output files are unsupported')
+ writeFileSync(join(output, 'cases.json'), json + '\n'); writeFileSync(join(output, 'review.html'), html)
+ console.log(JSON.stringify({ output, summary, rejected }))
+}
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try { main() } catch (error) { console.error(error.message); process.exitCode = 1 }
+}
diff --git a/scripts/build_commentary_short_preview.mjs b/scripts/build_commentary_short_preview.mjs
new file mode 100644
index 0000000..9b3466c
--- /dev/null
+++ b/scripts/build_commentary_short_preview.mjs
@@ -0,0 +1,122 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict'
+import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs'
+import { resolve, join, dirname, relative, isAbsolute } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { createHash } from 'node:crypto'
+import { fixtureBoards } from './build_commentary_blind_review.mjs'
+
+const root = fileURLToPath(new URL('../', import.meta.url))
+const read = path => JSON.parse(readFileSync(path, 'utf8'))
+const hash = text => createHash('sha256').update(text).digest('hex')
+const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
+const columns = 'ABCDEFGHJKLMNOPQRSTUVWXYZ'
+const within = (parent, child) => { const path = relative(parent, child); return path !== '' && !path.startsWith('..') && !isAbsolute(path) }
+
+function boardSvg(entry, phase) {
+ const step = 22, offset = 32, end = offset + (entry.boardSize - 1) * step, extent = end + 32
+ const locate = point => [offset + columns.indexOf(point[0]) * step, offset + (entry.boardSize - Number(point.slice(1))) * step]
+ let svg = `'
+}
+
+export function reviewHtml(data) {
+ const cards = data.cases.map((entry, index) => `${index + 1}. 第 ${entry.moveNumber} 手 · ${entry.playerColor === 'B' ? '黑' : '白'} ${escape(/^pass$/i.test(entry.playedMove) ? '停着' : entry.playedMove)}
${boardSvg(entry, 'before')}
${boardSvg(entry, 'after')}
红圈标记本手落点。
讲解原文
${escape(entry.markdown)}留下反馈(可选)
`).join('')
+ return `围棋短讲解预览${cards}`
+}
+
+export function main(args = process.argv.slice(2)) {
+ if (args.includes('--help')) { console.log('node scripts/build_commentary_short_preview.mjs [--ids position-19,position-20,position-8,position-16] [--result-dir PATH (repeatable)] [--out .tmp/commentary-short-preview]'); return }
+ const options = new Map()
+ for (let i = 0; i < args.length; i += 2) {
+ assert.ok(['--ids', '--result-dir', '--out', '--manifest', '--source'].includes(args[i]), `Unknown option ${args[i]}`)
+ assert.ok(args[i + 1] && !args[i + 1].startsWith('--'), `Missing ${args[i]} value`)
+ assert.ok(args[i] === '--result-dir' || !options.has(args[i]), `Duplicate ${args[i]}`)
+ options.set(args[i], [...(options.get(args[i]) ?? []), args[i + 1]])
+ }
+ const option = (name, fallback) => options.get(name)?.[0] ?? fallback
+ const ids = option('--ids', 'position-19,position-20,position-8,position-16').split(',')
+ assert.ok(ids.length >= 1 && ids.length <= 6 && new Set(ids).size === ids.length && ids.every(id => /^position-[1-9]\d*$/.test(id)), 'Select 1–6 unique position IDs')
+ const manifestPath = resolve(root, option('--manifest', '.tmp/commentary-live-v5/fixtures/manifest.json'))
+ const sourcePath = resolve(root, option('--source', '.tmp/concept-review-round2/cases.json'))
+ const resultDirs = (options.get('--result-dir') ?? ['.tmp/commentary-short-preview-results/candidate', '.tmp/commentary-live-v5/candidate']).map(path => resolve(root, path))
+ const output = resolve(root, option('--out', '.tmp/commentary-short-preview'))
+ const temporary = resolve(root, '.tmp')
+ assert.ok(within(temporary, output), 'Output must be a child of repository .tmp')
+ for (const input of [dirname(manifestPath), dirname(sourcePath), ...resultDirs]) assert.ok(output !== input && !within(output, input) && !within(input, output), 'Output must be separate from inputs')
+ let ancestor = output
+ while (!existsSync(ancestor)) ancestor = dirname(ancestor)
+ assert.ok(realpathSync(ancestor) === realpathSync(temporary) || within(realpathSync(temporary), realpathSync(ancestor)), 'Output ancestor escapes .tmp')
+ const fixtures = read(manifestPath).cases
+ const source = new Map(read(sourcePath).cases.map(entry => [entry.id, entry]))
+ const cases = ids.map(id => {
+ const fixture = fixtures.find(entry => entry.id === id)
+ assert.ok(fixture, `Unknown fixture ${id}`)
+ const resultPath = resultDirs.map(dir => join(dir, `${id}.json`)).find(existsSync)
+ assert.ok(resultPath, `Result not ready: ${id}`)
+ const result = read(resultPath)
+ assert.equal(result.success, true, `Unsuccessful result ${id}`)
+ assert.equal(result.id, id)
+ assert.equal(result.model, 'gpt-6-luna')
+ assert.equal(result.provider, 'codex-app-server')
+ assert.equal(result.variant, 'candidate')
+ assert.equal(result.identity?.fixtureHash, fixture.sha256)
+ assert.equal(result.request?.moveNumber, fixture.moveNumber)
+ assert.ok(typeof result.result?.markdown === 'string' && result.result.markdown.trim(), `Missing original commentary ${id}`)
+ assert.equal(result.boardVerification?.verified, true)
+ const fixturePath = resolve(fixture.filePath)
+ assert.ok(within(dirname(manifestPath), fixturePath), 'Fixture outside manifest directory')
+ const original = source.get(fixture.localCaseId)
+ assert.ok(original, `Missing source ${id}`)
+ const boards = fixtureBoards(fixture, original, readFileSync(fixturePath, 'utf8'))
+ for (const phase of ['before', 'after']) {
+ const flat = Array(boards.boardSize ** 2).fill(0)
+ for (const stone of boards[phase]) flat[(Number(stone.point.slice(1)) - 1) * boards.boardSize + columns.indexOf(stone.point[0])] = stone.color === 'B' ? 1 : -1
+ assert.deepEqual(result.boardVerification[phase], flat, `Result board differs from fixture ${id}`)
+ }
+ return { id, ...boards, markdown: result.result.markdown }
+ })
+ const data = { schemaVersion: 1, model: 'gpt-6-luna', reviewSetId: hash(output.toLowerCase()).slice(0, 24), cases }
+ for (const name of ['cases.json', 'review.html', 'feedback.json']) {
+ const path = join(output, name)
+ assert.ok(!existsSync(path) || realpathSync(path).toLowerCase() === path.toLowerCase(), 'Linked output files unsupported')
+ }
+ if (existsSync(join(output, 'cases.json'))) {
+ for (const previous of read(join(output, 'cases.json')).cases) assert.deepEqual(cases.find(entry => entry.id === previous.id), previous, 'Existing preview changed; use a new --out directory')
+ }
+ if (existsSync(join(output, 'feedback.json'))) assert.ok(read(join(output, 'feedback.json')).cases.every(entry => ids.includes(entry.id)), 'Existing feedback would be omitted; use a new --out directory')
+ mkdirSync(output, { recursive: true })
+ writeFileSync(join(output, 'cases.json'), JSON.stringify(data, null, 2) + '\n')
+ writeFileSync(join(output, 'review.html'), reviewHtml(data))
+ console.log(JSON.stringify({ output, cases: ids }))
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try { main() } catch (error) { console.error(error.message); process.exitCode = 1 }
+}
diff --git a/scripts/eval_commentary_pipeline.mjs b/scripts/eval_commentary_pipeline.mjs
new file mode 100644
index 0000000..3a20c02
--- /dev/null
+++ b/scripts/eval_commentary_pipeline.mjs
@@ -0,0 +1,228 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict'
+import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
+import { resolve, join } from 'node:path'
+import { createHash } from 'node:crypto'
+import { createServer } from 'node:http'
+import { readRecords, prepareCases, assertAnonymousSgf, assertSafeBoundary, teacherRequest, connectCdp, validatePipelineEvidence, validateMockCapture, canResume, validateImportedRecord, parseStoredResult, semanticProfile } from './lib/commentary_eval.mjs'
+
+const args = process.argv.slice(2)
+function option(name, fallback) {
+ const at = args.indexOf(`--${name}`)
+ return at < 0 ? fallback : args[at + 1]
+}
+const mode = args.includes('--prepare') ? 'prepare' : args.includes('--real') ? 'real' : args.includes('--mock') ? 'mock' : 'help'
+const directory = resolve(option('out', '.tmp/commentary-live'))
+const fixturesDir = join(directory, 'fixtures')
+const manifestPath = join(fixturesDir, 'manifest.json')
+const variant = option('variant', 'candidate')
+const phase = args.includes('--diagnostic') ? 'diagnostic' : 'formal'
+const prefetchedMaxVisits = Number(option('visits', '96'))
+assert.ok(Number.isInteger(prefetchedMaxVisits) && prefetchedMaxVisits >= 40 && prefetchedMaxVisits <= 3000, 'visits must be an integer between 40 and 3000')
+assert.ok(['candidate', 'baseline'].includes(variant), 'variant must be candidate or baseline')
+assert.ok(!(args.includes('--resume') && args.includes('--new-run')), 'Use --resume or --new-run, not both')
+const casesPath = resolve(option('cases', '.tmp/concept-review-round2/cases.json'))
+const dataPath = resolve(option('data', '.tmp/concept-data/comments.jsonl'))
+const enginePath = option('engine', '')
+const promptFile = option('prompt-file', '')
+const promptOverride = promptFile ? readFileSync(resolve(promptFile), 'utf8').trim() : undefined
+assert.ok(!promptFile || (promptOverride && promptOverride.length <= 4096), 'Prompt override must be nonempty and at most 4096 characters')
+const digest = value => createHash('sha256').update(value).digest('hex')
+
+async function mockServer() {
+ const captured = []
+ let current = null
+ const server = createServer((req, res) => {
+ let raw = ''
+ req.setEncoding('utf8')
+ req.on('data', chunk => { raw += chunk })
+ req.on('end', () => {
+ try {
+ if (req.url?.endsWith('/models')) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ data: [{ id: 'commentary-eval-mock' }] })); return }
+ const body = JSON.parse(raw || '{}')
+ captured.push({ position: current?.id ?? 'probe', body })
+ const messages = body.messages ?? []
+ const names = new Set((body.tools ?? []).map(tool => tool.function.name))
+ const tools = messages.filter(message => message.role === 'tool')
+ const needed = current ? [
+ ['sgf_readGameRecord', { gameId: current.gameId, maxMoves: current.moveNumber }],
+ ['knowledge_matchPosition', { moveNumber: current.moveNumber, maxResults: 4 }],
+ ['katago_analyzePosition', { gameId: current.gameId, moveNumber: current.moveNumber, maxVisits: 96 }],
+ ['board_captureTeachingImage', { moveNumber: current.moveNumber }]
+ ].filter(([name]) => names.has(name) && !tools.some(tool => tool.name === name || tool.tool_call_id === `eval_${name}`)) : []
+ const calls = needed.map(([name, input]) => ({ id: `eval_${name}`, type: 'function', function: { name, arguments: JSON.stringify(input) } }))
+ if (!current && names.has('readiness_check')) calls.push({ id: 'eval_readiness_check', type: 'function', function: { name: 'readiness_check', arguments: '{"value":"goagent"}' } })
+ const probeText = JSON.stringify(messages).includes('BLUE') ? 'BLUE' : 'OK'
+ const message = calls.length ? { role: 'assistant', content: '', tool_calls: calls } : { role: 'assistant', content: current ? `当前第${current.moveNumber}手的讲解应以棋谱、棋盘截图与 KataGo 候选证据为依据。先核对落点与局部气数,再比较实战手和候选点;局部目的及后续仍需结合变化验证,不能仅凭术语断言。` : probeText }
+ if (body.stream) {
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' })
+ const delta = calls.length ? { tool_calls: calls.map((call, index) => ({ index, ...call })) } : { content: message.content }
+ res.end(`data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason: calls.length ? 'tool_calls' : 'stop' }] })}\n\ndata: [DONE]\n\n`)
+ } else {
+ res.writeHead(200, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ choices: [{ message, finish_reason: calls.length ? 'tool_calls' : 'stop' }] }))
+ }
+ } catch { res.writeHead(500); res.end('Mock request rejected') }
+ })
+ })
+ await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen))
+ return { captured, endpoint: `http://127.0.0.1:${server.address().port}/v1`, setCase(value) { current = value }, close() { return new Promise(resolveClose => server.close(resolveClose)) } }
+}
+
+async function main() {
+ if (mode === 'help') {
+ console.log('Usage: node scripts/eval_commentary_pipeline.mjs --prepare|--mock|--real [--cases PATH] [--data JSONL] [--engine JSON] [--out DIR] [--cdp PORT] [--bundle PATH] [--variant candidate|baseline] [--onlyids position-1,position-2] [--limit 6] [--visits 96] [--resume|--new-run] [--diagnostic] [--continue-on-error] [--model gpt-6-luna]')
+ return
+ }
+ if (mode === 'prepare') {
+ const fixtures = prepareCases(readRecords(casesPath), readRecords(dataPath), enginePath ? readRecords(resolve(enginePath)) : [], fixturesDir)
+ if (existsSync(manifestPath)) {
+ const previous = JSON.parse(readFileSync(manifestPath, 'utf8'))
+ const byLocalId = new Map(previous.cases.map(item => [item.localCaseId, item]))
+ for (const fixture of fixtures) {
+ const prior = byLocalId.get(fixture.localCaseId)
+ assert.ok(prior, 'Existing manifest differs from selected cases; use a separate --out directory')
+ assert.match(prior.id, /^position-\d+$/, 'Invalid anonymous fixture ID')
+ fixture.id = prior.id
+ fixture.filePath = join(fixturesDir, `${prior.id}.sgf`)
+ }
+ fixtures.sort((a, b) => Number(a.id.split('-').at(-1)) - Number(b.id.split('-').at(-1)))
+ }
+ mkdirSync(fixturesDir, { recursive: true })
+ for (const fixture of fixtures) writeFileSync(fixture.filePath, fixture.sgf + '\n')
+ const manifest = { schemaVersion: 1, createdAt: new Date().toISOString(), files: fixtures.map(item => item.filePath), cases: fixtures.map(({ sgf, ...item }) => item) }
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
+ console.log(JSON.stringify({ mode, cases: fixtures.length, manifestPath, absentRules: fixtures.filter(item => !item.rules).length }))
+ return
+ }
+ assert.ok(existsSync(manifestPath), 'Run --prepare first')
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
+ const manifestIds = new Set(manifest.cases.map(item => item.localCaseId))
+ const privateRecords = readRecords(dataPath).filter(item => manifestIds.has(item.sample_id))
+ const onlyIds = option('onlyids', '').split(',').filter(Boolean)
+ const limit = Number(option('limit', '6'))
+ assert.ok(Number.isInteger(limit) && limit > 0, 'limit must be a positive integer')
+ let selected = manifest.cases.filter(item => !onlyIds.length || onlyIds.includes(item.id)).slice(0, limit)
+ assert.ok(selected.length, 'No cases selected')
+ for (const fixture of selected) {
+ const sgf = readFileSync(fixture.filePath, 'utf8').trim()
+ assertAnonymousSgf(sgf)
+ assert.equal(createHash('sha256').update(sgf).digest('hex'), fixture.sha256, 'Anonymous fixture differs from manifest')
+ }
+ if (mode === 'mock' && args.includes('--boundary-only')) {
+ const boundary = selected.map(fixture => ({ id: fixture.id, sgf: readFileSync(fixture.filePath, 'utf8'), request: teacherRequest(fixture, 'anonymous-game-hash', variant) }))
+ assertSafeBoundary(boundary, manifest.cases, privateRecords)
+ mkdirSync(join(directory, 'mock'), { recursive: true })
+ writeFileSync(join(directory, 'mock', 'boundary.json'), JSON.stringify(boundary, null, 2))
+ console.log(JSON.stringify({ mode, cases: selected.length, boundaryPassed: true, providerRequestCapture: false, note: 'Fixture/request boundary only. Use mock-provider mode for production provider capture.' }))
+ return
+ }
+ const outputDir = join(directory, mode === 'mock' ? 'mock' : variant)
+ mkdirSync(outputDir, { recursive: true })
+ const bundlePath = resolve(option('bundle', 'out/main/index.js'))
+ assert.ok(variant !== 'baseline' || option('bundle', ''), 'Baseline evaluation requires --bundle pointing to the running baseline main bundle')
+ assert.ok(existsSync(bundlePath), 'Application bundle not found')
+ const bundleHash = digest(readFileSync(bundlePath))
+ const client = await connectCdp(Number(option('cdp', mode === 'mock' ? '9225' : variant === 'baseline' ? '9224' : '9223')))
+ let mock = null
+ try {
+ const runningBuild = await client.evaluate('({bundleHash:window.__goagentCommentaryEvalBuild?.bundleHash})')
+ assert.equal(runningBuild?.bundleHash, bundleHash, 'Running application bundle differs from --bundle; use the evaluation bootstrap for loaded-build verification')
+ if (mode === 'mock') {
+ // Refuse configuration writes unless the existing instance explicitly identifies itself as the isolated mock profile.
+ const profileId = await client.evaluate('(async()=>{const d=await window.goagent.getDashboard();return d.settings.activeLlmConnectionId;})()')
+ assert.equal(profileId, 'commentary-eval-mock', 'Mock requires an isolated apphome with commentary-eval-mock active profile')
+ mock = await mockServer()
+ await client.evaluate(`(async()=>{const d=await window.goagent.getDashboard();await window.goagent.updateSettings({activeLlmConnectionId:'commentary-eval-mock',llmBaseUrl:${JSON.stringify(mock.endpoint)},llmModel:'commentary-eval-mock',llmApiKey:'local-eval-dummy',llmConnections:d.settings.llmConnections.map(p=>p.id==='commentary-eval-mock'?{...p,provider:'openai-compatible',authMode:'api-key',endpoint:${JSON.stringify(mock.endpoint)},model:'commentary-eval-mock',enabled:true}:p)});const p=await window.goagent.testLlmSettings({llmBaseUrl:'',llmApiKey:'',llmModel:''});return {ok:p.ok};})()`)
+ }
+ const expectedModel = mode === 'mock' ? 'commentary-eval-mock' : option('model', 'gpt-6-luna')
+ assert.ok(mode === 'mock' || expectedModel === 'gpt-6-luna', 'Real commentary evaluation is fixed to the user-selected gpt-6-luna')
+ const state = await client.evaluate(`(async()=>{const d=await window.goagent.getDashboard(); const p=d.settings.llmConnections.find(x=>x.id===d.settings.activeLlmConnectionId); return {ready:d.systemProfile?.llmConnection?.ready,provider:p?.provider,model:p?.model,runtimePath:p?.executablePath,katagoConfig:d.settings.katagoConfig,connectionStatus:d.systemProfile?.llmConnection?.status,settings:{reviewLanguage:d.settings.reviewLanguage,teacherTerminologyDensity:d.settings.teacherTerminologyDensity,teacherExplanationPace:d.settings.teacherExplanationPace,teacherVariationDetail:d.settings.teacherVariationDetail,katagoEngineMode:d.settings.katagoEngineMode,katagoModelPreset:d.settings.katagoModelPreset,katagoAnalysisSpeedMode:d.settings.katagoAnalysisSpeedMode,katagoAnalysisThreads:d.settings.katagoAnalysisThreads,katagoSearchThreadsPerAnalysisThread:d.settings.katagoSearchThreadsPerAnalysisThread}};})()`)
+ assert.equal(state.ready, true, 'Application LLM connection is not ready')
+ assert.equal(state.provider, mode === 'mock' ? 'openai-compatible' : 'codex-app-server', 'Unexpected provider for evaluation mode')
+ assert.equal(state.model, expectedModel, 'Active model does not match requested model')
+ assert.ok(state.katagoConfig && existsSync(state.katagoConfig), 'KataGo generated config is missing')
+ const engineConfig = readFileSync(state.katagoConfig, 'utf8')
+ const configuredHome = [...engineConfig.matchAll(/^\s*homeDataDir\s*=\s*(.*?)\s*$/gm)].at(-1)?.[1]
+ const expectedHome = resolve(option('tuning-home', '.tmp/concept-runtime/KataGoData'))
+ assert.ok(configuredHome && resolve(configuredHome).toLowerCase() === expectedHome.toLowerCase(), 'KataGo config must explicitly use the evaluation tuning home before any analysis starts')
+ await client.evaluate('(async()=>{const r=await window.goagent.importLibrary(); return {imported:r.imported.length};})()')
+ const games = await client.evaluate('(async()=>{const d=await window.goagent.getDashboard();return d.games.map(g=>({id:g.id,filePath:g.filePath}));})()')
+ const runtimeHash = state.runtimePath && existsSync(state.runtimePath) ? digest(readFileSync(state.runtimePath)) : null
+ const engineConfigHash = digest(engineConfig)
+ const meta = { schemaVersion: 2, variant, phase, provider: state.provider, model: state.model, bundleHash, runtimeHash, engineConfigHash, evaluatorRetryPolicy: 'Single attempt per selected case; production adapter retries/vision repairs remain part of the normal pipeline.', inferenceSettings: { effort: 'provider-default', temperature: 'provider-default', note: 'Production Codex adapter sets no explicit effort/temperature; paired runs must share the same CLI binary and account config.' }, createdAt: new Date().toISOString(), cases: selected.map(item => item.id), applicationSettings: state.settings, requestSettings: { coachLevel: 'intermediate', studentAgeRange: 'adult', teacherStyle: 'rigorous', toolPolicy: 'auto', prefetchedMaxVisits }, baselineMethod: variant === 'baseline' ? 'External application bundle must be the baseline commit; this script uses identical request settings.' : undefined }
+ const runStamp = `${Date.now()}`
+ const runDir = join(outputDir, 'runs', runStamp)
+ const identities = new Map(selected.map(fixture => [fixture.id, { model: state.model, provider: state.provider, variant, phase, fixtureHash: fixture.sha256, bundleHash, runtimeHash, engineConfigHash, promptHash: digest(teacherRequest(fixture, '', variant, promptOverride).prompt), settingsHash: digest(JSON.stringify({ app: state.settings, request: meta.requestSettings })) }]))
+ // Validate existing results before writing any run metadata. Successful results from another setup never count as a resumed case.
+ for (const fixture of selected) {
+ const path = join(outputDir, `${fixture.id}.json`)
+ if (!existsSync(path)) continue
+ const previous = parseStoredResult(readFileSync(path, 'utf8'))
+ if (args.includes('--resume') && previous.success) assert.ok(canResume(previous, identities.get(fixture.id)), `Resume identity mismatch for ${fixture.id}; choose a separate output directory`)
+ else if (previous.success && !args.includes('--new-run')) throw new Error(`Successful result already exists for ${fixture.id}; use --resume, --new-run or a separate output directory`)
+ }
+ mkdirSync(runDir, { recursive: true })
+ writeFileSync(join(runDir, 'run.json'), JSON.stringify(meta, null, 2))
+ if (!existsSync(join(outputDir, 'run.json'))) writeFileSync(join(outputDir, 'run.json'), JSON.stringify(meta, null, 2))
+ const outcomes = []
+ for (const fixture of selected) {
+ const resultPath = join(outputDir, `${fixture.id}.json`)
+ const identity = identities.get(fixture.id)
+ if (args.includes('--resume') && existsSync(resultPath) && canResume(parseStoredResult(readFileSync(resultPath, 'utf8')), identity)) { outcomes.push({ id: fixture.id, success: true, resumed: true }); console.log(`${variant} ${fixture.id}: resumed`); continue }
+ if (existsSync(resultPath)) writeFileSync(join(runDir, `${fixture.id}.previous.json`), readFileSync(resultPath))
+ const content = readFileSync(fixture.filePath, 'utf8')
+ const hash = createHash('sha1').update(content).digest('hex').slice(0, 12)
+ const matches = games.filter(game => game.id === hash)
+ assert.equal(matches.length, 1, 'Imported anonymous fixture hash must match exactly one game')
+ const gameId = matches[0].id
+ const imported = await client.evaluate(`window.goagent.getGameRecord(${JSON.stringify(gameId)}).then(r=>({boardSize:r.boardSize,moves:r.moves.map(m=>({color:m.color,gtp:m.gtp,row:m.row,col:m.col,pass:m.pass})),initialStones:r.initialStones}))`)
+ assert.equal(imported.boardSize, fixture.boardSize)
+ assert.equal(imported.moves.length, fixture.moveNumber)
+ const source = privateRecords.find(item => item.sample_id === fixture.localCaseId)
+ assert.ok(source, 'Local source record not found')
+ const boardVerification = validateImportedRecord(imported, source)
+ const request = teacherRequest(fixture, gameId, variant, promptOverride)
+ if (phase === 'diagnostic') request.runId += `-diagnostic-${runStamp}`
+ const start = Date.now()
+ mock?.setCase({ ...fixture, gameId })
+ console.log(`${variant} ${fixture.id}: running`)
+ let payload
+ try {
+ payload = await client.evaluate(`(async()=>{const request=${JSON.stringify(request)};let analysis;const progress=[];const dispose=window.goagent.onTeacherRunProgress(p=>{if(p.runId===request.runId)progress.push(p)});try{analysis=await window.goagent.analyzePosition({gameId:request.gameId,moveNumber:request.moveNumber,maxVisits:${prefetchedMaxVisits}});const result=await window.goagent.runTeacherTask({...request,prefetchedAnalysis:analysis});return {analysis,result,progress};}catch(error){return {analysis,progress,pipelineError:String(error)};}finally{dispose();}})()`)
+ assert.ok(!payload.pipelineError, payload.pipelineError)
+ validatePipelineEvidence(payload, fixture)
+ if (mock) {
+ const requests = mock.captured.filter(item => item.position === fixture.id)
+ writeFileSync(join(runDir, `${fixture.id}.requests.json`), JSON.stringify(requests, null, 2))
+ assertSafeBoundary(requests, manifest.cases, privateRecords)
+ validateMockCapture(requests, { gameId, moveNumber: fixture.moveNumber, requireConceptEvidence: variant === 'candidate' })
+ writeFileSync(join(runDir, `${fixture.id}.boundary.json`), JSON.stringify({ passed: true, capturedRequests: requests.length, scope: 'Production OpenAI-compatible teacher messages and tool outputs; Codex stdio adapter is validated separately by real execution.' }, null, 2))
+ }
+ const profileHash = digest(JSON.stringify(semanticProfile(payload.result.studentProfile)))
+ const artifact = JSON.stringify({ ...meta, id: fixture.id, identity, success: true, elapsedMs: Date.now() - start, request, boardVerification, profileHash, accountCondition: 'Same account and account config is an operational prerequisite; authentication data is not inspected.', ...payload }, null, 2)
+ writeFileSync(join(runDir, `${fixture.id}.json`), artifact)
+ writeFileSync(resultPath, artifact)
+ writeFileSync(join(runDir, `${fixture.id}.md`), payload.result.markdown)
+ writeFileSync(join(outputDir, `${fixture.id}.md`), payload.result.markdown)
+ outcomes.push({ id: fixture.id, success: true, elapsedMs: Date.now() - start })
+ console.log(`${variant} ${fixture.id}: saved`)
+ } catch (error) {
+ const failed = JSON.stringify({ ...meta, id: fixture.id, identity, success: false, elapsedMs: Date.now() - start, request, ...payload, error: String(error) }, null, 2)
+ writeFileSync(join(runDir, `${fixture.id}.json`), failed)
+ writeFileSync(resultPath, failed)
+ if (mock) writeFileSync(join(runDir, `${fixture.id}.requests.json`), JSON.stringify(mock.captured.filter(item => item.position === fixture.id), null, 2))
+ outcomes.push({ id: fixture.id, success: false, elapsedMs: Date.now() - start, error: String(error) })
+ console.log(`${variant} ${fixture.id}: failed; artifact saved`)
+ if (!args.includes('--continue-on-error')) throw new Error(`${variant} ${fixture.id} failed; see local error artifact`)
+ }
+ }
+ const summary = { ...meta, denominator: selected.length, succeeded: outcomes.filter(item => item.success).length, failed: outcomes.filter(item => !item.success).length, resumed: outcomes.filter(item => item.resumed).length, completedAt: new Date().toISOString(), outcomes }
+ writeFileSync(join(runDir, 'summary.json'), JSON.stringify(summary, null, 2))
+ if (existsSync(join(outputDir, 'summary.json'))) writeFileSync(join(runDir, 'previous-summary.json'), readFileSync(join(outputDir, 'summary.json')))
+ writeFileSync(join(outputDir, 'summary.json'), JSON.stringify(summary, null, 2))
+ console.log(JSON.stringify({ variant, phase, denominator: summary.denominator, succeeded: summary.succeeded, failed: summary.failed, summaryPath: join(runDir, 'summary.json') }))
+ if (summary.failed) process.exitCode = 1
+ } finally { client.close(); if (mock) await mock.close() }
+}
+main().catch(error => { console.error(error.message); process.exitCode = 1 })
diff --git a/scripts/eval_move_concepts.mjs b/scripts/eval_move_concepts.mjs
new file mode 100644
index 0000000..a71def1
--- /dev/null
+++ b/scripts/eval_move_concepts.mjs
@@ -0,0 +1,145 @@
+import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { loadConceptModule } from './lib/concept_modules.mjs'
+
+const { recognizeMoveConcepts } = await loadConceptModule('src/main/services/knowledge/moveConcepts.ts')
+const baselineCommit = 'a5c801c'
+const { recognizeShapes } = await loadConceptModule('src/main/services/knowledge/shapeRecognitionEngine.ts', baselineCommit)
+const dataPath = process.argv[2] ?? '.tmp/concept-data/comments.jsonl'
+const output = process.argv[3] ?? '.tmp/concept-review'
+// A later round can retain the original sample identities despite changed concepts.
+const pinnedCases = process.argv[4] ? JSON.parse(readFileSync(process.argv[4], 'utf8')).cases : undefined
+const pinnedIds = pinnedCases && new Set(pinnedCases.map((entry) => entry.id))
+const priorFeedback = process.argv[5] ? JSON.parse(readFileSync(process.argv[5], 'utf8')).cases : []
+const priorById = new Map(priorFeedback.map((entry) => [entry.id, entry]))
+if (!existsSync(dataPath)) throw new Error('先运行 python scripts/experiments/prepare_comment_concepts.py --games 1000')
+const rows = readFileSync(dataPath, 'utf8').trim().split('\n').map((line) => JSON.parse(line))
+const columns = 'ABCDEFGHJKLMNOPQRST'
+const cases = []
+const counts = { positions: 0, abstained: 0, ambiguous: 0, observed: 0, extensionHypotheses: 0 }
+const buckets = new Map()
+const games = new Set()
+for (const row of rows.filter((row) => row.split === 'test')) {
+ const boardSnapshot = row.board_before.flatMap((value, index) => value ? [{ color: value === 1 ? 'B' : 'W', point: `${columns[index % 19]}${Math.floor(index / 19) + 1}` }] : [])
+ const [playerColor, playedMove] = row.played_move
+ const moveHistory = row.moves_before.map(([color, point]) => ({ color, point }))
+ const concepts = recognizeMoveConcepts({ boardSize: 19, boardSnapshot, playedMove, playerColor, moveHistory })
+ counts.positions++
+ counts[concepts.status]++
+ if (concepts.intents.some((intent) => intent.kind === 'side-extension')) counts.extensionHypotheses++
+ const observed = concepts.geometry.filter((geometry) => geometry.status === 'observed')
+ const bucket = concepts.intents.some((intent) => intent.kind === 'side-extension') ? 'side-extension' : concepts.status === 'ambiguous' ? 'ambiguous' : observed[0]?.kind
+ if (pinnedIds) {
+ if (!pinnedIds.has(row.sample_id)) continue
+ } else {
+ if (!['side-extension', 'ambiguous', 'one-space-jump', 'small-knight', 'diagonal', 'two-space-jump', 'large-knight'].includes(bucket)) continue
+ if ((buckets.get(bucket) ?? 0) >= 3 || games.has(row.source_game_key ?? row.game_sha256)) continue
+ }
+ buckets.set(bucket, (buckets.get(bucket) ?? 0) + 1)
+ games.add(row.source_game_key ?? row.game_sha256)
+ const moveNumber = row.moves_after.length
+ const legacy = recognizeShapes({ boardSize: 19, boardSnapshot, playedMove, playerColor, moveNumber, totalMoves: row.moves_after.length, recentMoves: [], maxResults: 4 })
+ cases.push({ id: row.sample_id, gameId: row.game_id, moveNumber, playerColor, playedMove, boardSnapshot, moveHistory, initialStones: row.initial_stones, bucket,
+ weakMentions: Object.keys(row.labels).filter((key) => row.labels[key]), concepts,
+ legacy: legacy.map((shape) => ({ shapeType: shape.shapeType, wording: shape.recognition, evidence: shape.evidence, safeWording: shape.safeWording })),
+ review: { geometryCorrect: null, intendedTerm: '', intentionCorrect: null, useful: null, notes: '' } })
+}
+if (pinnedCases) {
+ const order = new Map(pinnedCases.map((entry, index) => [entry.id, index]))
+ cases.sort((a, b) => order.get(a.id) - order.get(b.id))
+ if (cases.length !== pinnedCases.length) throw new Error('Pinned review cases missing from the data')
+}
+mkdirSync(output, { recursive: true })
+writeFileSync(join(output, 'cases.json'), JSON.stringify({ baselineCommit, warning: '自动词语标签不是围棋真值。以下是概念层候选措辞,不是最终LLM解说。', counts, cases }, null, 2))
+const escape = (value) => String(value).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char])
+function boardSvg(entry) {
+ const step = 22, offset = 32, size = 19, end = offset + (size - 1) * step
+ let svg = `'
+}
+const cards = cases.map((entry, index) => {
+ const prior = priorById.get(entry.id)
+ const previous = pinnedCases?.find((old) => old.id === entry.id)
+ const candidates = entry.concepts.relationCandidates ?? []
+ const history = entry.moveHistory.slice(-4).map((move) => `${move.color} ${move.point}`).join(' → ')
+ return `${boardSvg(entry)}
落子前局面 · 红圈:本手 ${entry.playerColor} ${escape(entry.playedMove)} · 蓝圈:己棋锚点 · 橙框:敌方目标
最近实战:${escape(history || '无')}。初始摆子 ${entry.initialStones?.length ?? 0} 个;手数不含摆子。
${index+1}. ${escape(entry.id)} · 第 ${entry.moveNumber} 手
${escape(entry.bucket)} · 原棋谱
+ ${prior ? `你对第一版的反馈
${escape(prior.intendedTerm)}
${escape(prior.notes)}
第一版评价:${escape(prior.geometryCorrect)};${escape(prior.useful)}。这些反馈用于修改,不计入第二版独立准确率。
` : ''}
+ ${previous && prior ? `第一版:${escape(previous.concepts.wording)}
` : ''}
+ ${pinnedCases ? '第二版:关系与目的候选' : '分层概念候选'}
${escape(entry.concepts.wording)}
+ ${candidates.map((candidate) => `${candidate.purposeHypothesis ? `目的假设:${escape(candidate.purposeHypothesis.wording)}
` : ''}`).join('')}
+ ${entry.concepts.intents.map((intent) => `${escape(intent.wording)}
`).join('')}
+ 关系证据和仍待验证的地方
${entry.concepts.relations.map((relation) => `- ${escape(relation.evidence)}
`).join('')}${candidates.flatMap((candidate) => [...candidate.evidence, ...candidate.counterEvidence]).map((text) => `- ${escape(text)}
`).join('')}${entry.concepts.geometry.map((g) => `- ${escape(g.wording)}
`).join('')}${entry.concepts.intents.flatMap((intent) => intent.counterEvidence).map((text) => `- ${escape(text)}
`).join('')}
+ 原局部规则输出
${entry.legacy.length ? entry.legacy.map((shape) => `${escape(shape.safeWording)}:${escape(shape.wording)}
`).join('') : '没有匹配。这里没有候选评价数据,不能据此比较完整解说。
'}
+ 原评论提及:${escape(entry.weakMentions.join(', ') || '没有实验标签')}。这不是本手正确术语。
`
+}).join('')
+const html = `围棋概念人工审阅围棋概念人工审阅
共 ${cases.length} 个不同棋谱的测试局面。请判断:与具体敌棋或角部的关系术语是否正确、目的解释是否有帮助。前6例用于修正;后15例仍待独立审阅。
这里展示真实概念函数的输出,尚未验证最终 LLM 解说质量。研究数据按整盘隔离;评论标签仅用于抽样。反馈自动保存到本浏览器,也可以下载后交给 Codex。
${cards}`
+const projectSaveScript = ``
+writeFileSync(join(output, 'review.html'), html
+ .replace('', '')
+ .replace('反馈自动保存到本浏览器,也可以下载后交给 Codex。', '填写时自动暂存在本浏览器;点“保存反馈到项目”后,Codex 可直接读取。下载 JSON 仅用于备份。')
+ .replace('