Skip to content

Commit dd4c9e1

Browse files
haochaoc
authored andcommitted
feat: improve teacher evidence and board polish
1 parent 05512a2 commit dd4c9e1

28 files changed

Lines changed: 3816 additions & 159 deletions

docs/TEACHING_ARTIFACTS.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# GoAgent Teaching Artifacts
2+
3+
Teaching Artifacts turn an AI teacher reply into a compact, shareable review
4+
object. The renderer displays structured data; the runtime owns validation,
5+
redaction, pruning and static HTML export.
6+
7+
## Phase 1: Structured Runtime Artifact
8+
9+
The teacher agent may call `artifact.createTeachingArtifact` after it has enough
10+
tool evidence. The tool accepts agent JSON artifact data, validates it at runtime,
11+
normalizes candidate ranks, trims oversized arrays, redacts secrets/local paths,
12+
and regenerates the static export HTML from safe structured fields.
13+
14+
`TeacherRunResult.artifact` prefers the validated agent JSON artifact. If the
15+
agent does not create a valid artifact, GoAgent falls back to the runtime-derived
16+
artifact built from KataGo analysis, structured teacher text, knowledge matches
17+
and recommended problems.
18+
19+
No evidence means no artifact. A title and summary alone are not enough.
20+
21+
## Evidence Sources
22+
23+
Artifacts may use:
24+
25+
- KataGo current-position analysis: candidates, winrate, score lead, visits and PV.
26+
- Structured teacher result: key mistakes, training ideas and follow-up intent.
27+
- Vision evidence metadata: whether a board image was attached and validated.
28+
- Local knowledge matches: joseki, life-and-death, tesuji, shape and concept matches.
29+
- Recommended training problems generated from the local knowledge matcher.
30+
31+
Artifacts must not store full base64 board images in session history or reports.
32+
Only metadata and compact teaching facts should be persisted.
33+
34+
## Phase 2: Static HTML Export
35+
36+
`exportHtml` is always produced by GoAgent code, not by injected model HTML. It is
37+
safe, static and self-contained:
38+
39+
- no script tags or event handlers,
40+
- no remote assets,
41+
- no base64 images,
42+
- no local filesystem paths,
43+
- no API keys or bearer tokens,
44+
- escaped text content.
45+
46+
The UI must not inject artifact HTML back into the app with
47+
`dangerouslySetInnerHTML`. Users can copy or export the static HTML as a local
48+
file, but the app should render the structured artifact fields directly.
49+
There are no remote scripts and no remote assets in the static export.
50+
51+
## Phase 3: Sandbox HTML Foundation
52+
53+
Richer sandbox HTML is typed separately as `artifact.sandboxHtml`. It is never
54+
merged into `exportHtml`.
55+
56+
The default script policy is `disabled`: scripts, inline event handlers and
57+
`javascript:` URLs are removed during validation. A future renderer may opt into
58+
`sandbox-iframe-only`, but only inside a sandboxed iframe and only after the same
59+
runtime checks reject remote assets, base64 images, local paths and secrets.
60+
61+
This phase intentionally does not change renderer UI. It only establishes the
62+
type and runtime validation boundary for future interactive artifacts.
63+
64+
## Runtime Contract
65+
66+
`buildTeacherArtifact` creates a fallback artifact from trusted runtime evidence.
67+
`createTeachingArtifact` and `validateTeachingArtifact` accept agent JSON artifact
68+
input and return only a validated artifact. Both paths generate `exportHtml` via
69+
`renderTeacherArtifactHtml` and should pass `validateStaticTeacherArtifactHtml`.
70+
71+
Candidate order is display-normalized: KataGo zero-based `order=0` is rank 1 and
72+
renders as the first choice, never as "第 0 选".

docs/VISUAL_QA_CAPTURE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ The script writes screenshots to:
3232
release-evidence/ui-gallery/
3333
```
3434

35+
It captures `.teacher-artifact-card` separately as:
36+
37+
```text
38+
release-evidence/ui-gallery/teaching-artifact-card.png
39+
```
40+
3541
Do not commit local screenshot evidence by default. Attach it to the PR or release evidence bundle when doing manual QA.
3642

3743
## Required Evidence
@@ -43,6 +49,7 @@ Do not commit local screenshot evidence by default. Attach it to the PR or relea
4349
- KeyMoveNavigator strip
4450
- BoardInsightPanel
4551
- TeacherRunCardPro structured result
52+
- Teaching Artifact coaching card, including copy action and key move links
4653
- TeacherComposerPro focus and busy states
4754
- StudentRailCard
4855
- SGF StudentBindingDialog

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"eval:teacher-style": "node scripts/eval_teacher_style.mjs",
3838
"eval:teacher-session": "node scripts/eval_teacher_session.mjs",
3939
"eval:tts-provider-policy": "node scripts/eval_tts_provider_policy.mjs",
40+
"check:teacher-artifact": "node --test tests/teacher-artifact-contract.test.mjs",
4041
"prepare:tts-assets": "node scripts/prepare_tts_assets.mjs",
4142
"check:tts-assets": "node scripts/check_tts_assets.mjs",
4243
"smoke:tts": "node scripts/smoke_tts.mjs",
@@ -46,7 +47,7 @@
4647
"website:check": "pnpm --dir website build",
4748
"check:website": "node scripts/check_website.mjs",
4849
"smoke:release-artifacts": "node scripts/release_artifact_smoke.mjs",
49-
"check:teacher-quality": "pnpm build && pnpm eval:teacher && pnpm eval:claims && pnpm eval:quality-gate && pnpm check:knowledge-sources && pnpm eval:knowledge-coverage && pnpm eval:shape-recognition && pnpm eval:move-range && pnpm eval:vision-evidence && pnpm eval:engine-silver && pnpm eval:teacher-style && pnpm eval:teacher-session && pnpm eval:tts-provider-policy",
50+
"check:teacher-quality": "pnpm build && pnpm eval:teacher && pnpm eval:claims && pnpm eval:quality-gate && pnpm check:knowledge-sources && pnpm eval:knowledge-coverage && pnpm eval:shape-recognition && pnpm eval:move-range && pnpm eval:vision-evidence && pnpm eval:engine-silver && pnpm eval:teacher-style && pnpm eval:teacher-session && pnpm eval:tts-provider-policy && pnpm check:teacher-artifact",
5051
"package": "pnpm dist",
5152
"dist": "pnpm build && electron-builder",
5253
"check:deep-teacher-quality": "pnpm check:teacher-quality && pnpm eval:real-teaching:strict",

scripts/capture_ui_gallery.mjs

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
#!/usr/bin/env node
22
import { mkdir } from 'node:fs/promises'
3-
import { join, resolve } from 'node:path'
3+
import { dirname, join, resolve } from 'node:path'
44
import { spawnSync } from 'node:child_process'
55

66
const url = process.env.GOAGENT_UI_GALLERY_URL ?? 'http://localhost:5173/#/ui-gallery'
77
const outDir = resolve(process.env.GOAGENT_UI_GALLERY_OUT ?? 'release-evidence/ui-gallery')
8+
const captureTargets = [
9+
['board', '.ui-gallery__panel--board'],
10+
['teacher-card', '.ui-gallery__panel--teacher'],
11+
['teaching-artifact-card', '.teacher-artifact-card'],
12+
['timeline', '.ks-timeline-v2'],
13+
['diagnostics', '.diagnostics-page'],
14+
['settings-readiness', '.beta-acceptance-panel']
15+
]
816

917
async function loadPlaywright() {
1018
try {
@@ -16,23 +24,64 @@ async function loadPlaywright() {
1624

1725
async function captureWithCliFallback() {
1826
await mkdir(outDir, { recursive: true })
19-
const overviewPath = join(outDir, 'ui-gallery-overview.png')
27+
const whichResult = spawnSync('npx', ['--yes', '-p', 'playwright', 'which', 'playwright'], {
28+
encoding: 'utf8',
29+
stdio: ['ignore', 'pipe', 'inherit']
30+
})
31+
const playwrightBin = whichResult.stdout.trim()
32+
if (whichResult.status !== 0 || !playwrightBin) {
33+
throw new Error('Playwright package is not installed and npx could not locate it. Run pnpm dev, then capture this route manually: ' + url)
34+
}
35+
const playwrightNodeModules = dirname(dirname(playwrightBin))
36+
const fallbackScript = `
37+
const { mkdir } = require('node:fs/promises')
38+
const { createRequire } = require('node:module')
39+
const { join } = require('node:path')
40+
const requireFromPlaywright = createRequire(join(process.env.PLAYWRIGHT_NODE_MODULES, 'playwright', 'package.json'))
41+
const { chromium } = requireFromPlaywright('playwright')
42+
43+
;(async () => {
44+
const url = process.env.GOAGENT_CAPTURE_URL
45+
const outDir = process.env.GOAGENT_CAPTURE_OUT
46+
const targets = ${JSON.stringify(captureTargets)}
47+
48+
await mkdir(outDir, { recursive: true })
49+
const browser = await chromium.launch()
50+
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 })
51+
await page.goto(url, { waitUntil: 'networkidle' })
52+
await page.screenshot({ path: join(outDir, 'ui-gallery-overview.png'), fullPage: true })
53+
for (const [name, selector] of targets) {
54+
const locator = page.locator(selector).first()
55+
if (await locator.count()) {
56+
await locator.screenshot({ path: join(outDir, name + '.png') })
57+
}
58+
}
59+
await browser.close()
60+
})().catch((error) => {
61+
console.error(error.message)
62+
process.exit(1)
63+
})
64+
`
2065
const result = spawnSync('npx', [
2166
'--yes',
67+
'-p',
2268
'playwright',
23-
'screenshot',
24-
'--viewport-size=1440,1100',
25-
'--full-page',
26-
'--wait-for-selector=.ui-gallery',
27-
url,
28-
overviewPath
69+
'node',
70+
'-e',
71+
fallbackScript
2972
], {
73+
env: {
74+
...process.env,
75+
GOAGENT_CAPTURE_URL: url,
76+
GOAGENT_CAPTURE_OUT: outDir,
77+
PLAYWRIGHT_NODE_MODULES: playwrightNodeModules
78+
},
3079
stdio: 'inherit'
3180
})
3281
if (result.status !== 0) {
33-
throw new Error('Playwright package is not installed and npx playwright screenshot failed. Run pnpm dev, then capture this route manually: ' + url)
82+
throw new Error('Playwright package is not installed and npx Playwright capture failed. Run pnpm dev, then capture this route manually: ' + url)
3483
}
35-
console.log(`Captured UI Gallery overview in ${overviewPath}`)
84+
console.log(`Captured UI Gallery screenshots in ${outDir}`)
3685
}
3786

3887
async function capture() {
@@ -48,15 +97,7 @@ async function capture() {
4897
await page.goto(url, { waitUntil: 'networkidle' })
4998
await page.screenshot({ path: join(outDir, 'ui-gallery-overview.png'), fullPage: true })
5099

51-
const targets = [
52-
['board', '.ui-gallery__panel--board'],
53-
['teacher-card', '.ui-gallery__panel--teacher'],
54-
['timeline', '.ks-timeline-v2'],
55-
['diagnostics', '.diagnostics-page'],
56-
['settings-readiness', '.beta-acceptance-panel']
57-
]
58-
59-
for (const [name, selector] of targets) {
100+
for (const [name, selector] of captureTargets) {
60101
const locator = page.locator(selector).first()
61102
if (await locator.count()) {
62103
await locator.screenshot({ path: join(outDir, `${name}.png`) })

scripts/smoke_tts.mjs

Lines changed: 121 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
#!/usr/bin/env node
2-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
2+
import { createHash } from 'node:crypto'
3+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
34
import { join } from 'node:path'
45
import { spawn, spawnSync } from 'node:child_process'
6+
import { devNull } from 'node:os'
57

68
const root = process.cwd()
79
const strict = process.env.GOAGENT_TTS_SMOKE_STRICT === '1'
810
const assetRoot = join(root, 'data', 'tts', 'kokoro', 'zh-CN')
9-
const cacheRoot = join(root, process.env.GOAGENT_APP_HOME || '.goagent-smoke', 'cache', 'tts', 'kokoro-bundled')
11+
const smokeHome = join(root, process.env.GOAGENT_APP_HOME || '.goagent-smoke')
12+
const cacheRoot = join(smokeHome, 'cache', 'tts', 'kokoro-bundled')
13+
const runtimeRoot = join(smokeHome, 'runtime', 'tts-python')
14+
const venvRoot = join(runtimeRoot, 'venv')
15+
const venvPython = join(venvRoot, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python3')
16+
const requirementsPath = join(root, 'scripts', 'requirements-tts.txt')
17+
const requirementsStamp = join(runtimeRoot, 'requirements.sha256')
1018
const failures = []
1119
let strictSynthesisOk = false
1220

@@ -29,38 +37,125 @@ function splitLauncher(commandLine) {
2937
return { command: commandLine, args: [] }
3038
}
3139

32-
function runMisakiG2p(text) {
33-
const script = join(root, 'scripts', 'tts_misaki_zh_g2p.py')
40+
function spawnChecked(command, args, options = {}) {
41+
const result = spawnSync(command, args, {
42+
encoding: 'utf8',
43+
maxBuffer: 1024 * 1024,
44+
...options
45+
})
46+
if (result.status !== 0) {
47+
const detail = result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status}`
48+
throw new Error(detail)
49+
}
50+
return result
51+
}
52+
53+
function isSupportedPython(launcher) {
54+
const result = spawnSync(launcher.command, [
55+
...launcher.args,
56+
'-c',
57+
'import sys; raise SystemExit(0 if sys.version_info.major == 3 and 10 <= sys.version_info.minor <= 13 else 1)'
58+
], { encoding: 'utf8' })
59+
return result.status === 0
60+
}
61+
62+
function hasMisakiZh(launcher) {
63+
const result = spawnSync(launcher.command, [
64+
...launcher.args,
65+
'-c',
66+
'from misaki.zh import ZHG2P; ZHG2P(version="1.1"); print("ok")'
67+
], {
68+
encoding: 'utf8',
69+
maxBuffer: 1024 * 1024,
70+
env: {
71+
...process.env,
72+
PYTHONIOENCODING: 'utf-8'
73+
}
74+
})
75+
return result.status === 0
76+
}
77+
78+
function pipEnv() {
79+
return {
80+
...process.env,
81+
PIP_CONFIG_FILE: devNull,
82+
PIP_INDEX_URL: 'https://pypi.org/simple',
83+
PIP_DISABLE_PIP_VERSION_CHECK: '1',
84+
PIP_NO_INPUT: '1'
85+
}
86+
}
87+
88+
function ensureSmokePythonRuntime(baseLauncher) {
89+
mkdirSync(runtimeRoot, { recursive: true })
90+
if (!existsSync(venvPython)) {
91+
spawnChecked(baseLauncher.command, [...baseLauncher.args, '-m', 'venv', venvRoot], { timeout: 120_000 })
92+
}
93+
const venvLauncher = { command: venvPython, args: [] }
94+
if (!isSupportedPython(venvLauncher)) {
95+
throw new Error(`smoke TTS venv is not Python 3.10-3.13: ${venvPython}`)
96+
}
97+
98+
const requirements = readFileSync(requirementsPath, 'utf8')
99+
const digest = createHash('sha256').update(requirements).digest('hex')
100+
const installedDigest = existsSync(requirementsStamp) ? readFileSync(requirementsStamp, 'utf8').trim() : ''
101+
if (!hasMisakiZh(venvLauncher) || installedDigest !== digest) {
102+
spawnChecked(venvPython, ['-m', 'ensurepip', '--upgrade'], { timeout: 120_000 })
103+
try {
104+
spawnChecked(venvPython, ['-m', 'pip', 'install', '-r', requirementsPath], { timeout: 360_000, env: pipEnv() })
105+
} catch (firstError) {
106+
spawnChecked(venvPython, ['-m', 'pip', 'install', '-r', requirementsPath], { timeout: 360_000 })
107+
}
108+
writeFileSync(requirementsStamp, `${digest}\n`, 'utf8')
109+
}
110+
if (!hasMisakiZh(venvLauncher)) {
111+
throw new Error(`misaki[zh] is still unavailable after installing ${requirementsPath}`)
112+
}
113+
return venvLauncher
114+
}
115+
116+
function resolveMisakiPython() {
34117
const errors = []
118+
let firstSupported = null
35119
for (const candidate of pythonCandidates()) {
36120
const launcher = splitLauncher(candidate)
37-
const probe = spawnSync(launcher.command, [
38-
...launcher.args,
39-
'-c',
40-
'import sys; raise SystemExit(0 if sys.version_info.major == 3 and 10 <= sys.version_info.minor <= 13 else 1)'
41-
], { encoding: 'utf8' })
42-
if (probe.status !== 0) {
121+
if (!isSupportedPython(launcher)) {
43122
errors.push(`${candidate}: not Python 3.10-3.13`)
44123
continue
45124
}
46-
const result = spawnSync(launcher.command, [...launcher.args, script], {
47-
input: JSON.stringify({ text }),
48-
encoding: 'utf8',
49-
maxBuffer: 1024 * 1024,
50-
env: {
51-
...process.env,
52-
PYTHONIOENCODING: 'utf-8',
53-
GOAGENT_TTS_ALLOW_UNKNOWN_PHONEMES: '1'
54-
}
55-
})
56-
if (result.status === 0) {
57-
const jsonLine = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
58-
if (!jsonLine) throw new Error(`Misaki zh G2P returned no JSON: ${result.stdout}`)
59-
return JSON.parse(jsonLine)
125+
firstSupported ??= { ...launcher, label: candidate }
126+
if (hasMisakiZh(launcher)) return { ...launcher, label: candidate }
127+
errors.push(`${candidate}: Python OK, misaki[zh] not installed`)
128+
}
129+
if (!firstSupported) {
130+
throw new Error(`Misaki zh G2P unavailable. Tried ${errors.join(';')}`)
131+
}
132+
try {
133+
return ensureSmokePythonRuntime(firstSupported)
134+
} catch (error) {
135+
errors.push(`managed smoke venv: ${error instanceof Error ? error.message : String(error)}`)
136+
throw new Error(`Misaki zh G2P unavailable. Tried ${errors.join(';')}`)
137+
}
138+
}
139+
140+
function runMisakiG2p(text) {
141+
const script = join(root, 'scripts', 'tts_misaki_zh_g2p.py')
142+
const launcher = resolveMisakiPython()
143+
const result = spawnSync(launcher.command, [...launcher.args, script], {
144+
input: JSON.stringify({ text }),
145+
encoding: 'utf8',
146+
maxBuffer: 1024 * 1024,
147+
env: {
148+
...process.env,
149+
PYTHONIOENCODING: 'utf-8',
150+
GOAGENT_TTS_ALLOW_UNKNOWN_PHONEMES: '1'
60151
}
61-
errors.push(`${candidate}: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`)
152+
})
153+
if (result.status === 0) {
154+
const jsonLine = result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
155+
if (!jsonLine) throw new Error(`Misaki zh G2P returned no JSON: ${result.stdout}`)
156+
return JSON.parse(jsonLine)
62157
}
63-
throw new Error(`Misaki zh G2P unavailable. Tried ${errors.join(';')}`)
158+
throw new Error(`${launcher.label ?? launcher.command}: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`}`)
64159
}
65160

66161
function inspectWavAudio(path) {

0 commit comments

Comments
 (0)