Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions docs/VISUAL_QA_CAPTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,32 @@ Sprint 7 adds an internal UI Gallery for repeatable visual review. It uses mock

## Open the Gallery

Start the app in development mode:
Build and open the gallery with the self-contained capture command:

```bash
pnpm dev
pnpm capture:ui-gallery
```

Open either route:
The command builds the current renderer, serves it on an available localhost port, captures the required states with Playwright, and shuts the server down. It does not require KataGo, LLM credentials, or a separately running dev server.

```text
http://localhost:5173/#/ui-gallery
http://localhost:5173/?ui-gallery=1
To inspect the gallery interactively instead, start the Vite renderer:

```bash
pnpm dev:vite
```

Then open `http://localhost:5173/#/ui-gallery`.

## Capture Screenshots

If Playwright is available locally, run:
For a faster repeat capture after an unchanged build, run:

```bash
GOAGENT_UI_GALLERY_URL="http://localhost:5173/#/ui-gallery" \
node scripts/capture_ui_gallery.mjs
pnpm capture:ui-gallery -- --skip-build
```

Set `GOAGENT_BROWSER_PATH` when Chrome/Edge/Chromium is installed in a non-standard location. Set `GOAGENT_UI_GALLERY_URL` only when capturing an already-running renderer server.

The script writes screenshots to:

```text
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"website:dev": "pnpm --dir website dev",
"website:check": "pnpm --dir website build",
"check:website": "node scripts/check_website.mjs",
"capture:ui-gallery": "node scripts/capture_ui_gallery.mjs",
"smoke:release-artifacts": "node scripts/release_artifact_smoke.mjs",
"smoke:windows-packaged": "node scripts/smoke_windows_packaged_app.mjs",
"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:katago-trace && pnpm eval:engine-silver && pnpm eval:teacher-style && pnpm eval:teacher-session && pnpm eval:tts-provider-policy && pnpm check:teacher-artifact",
Expand Down
157 changes: 137 additions & 20 deletions scripts/capture_ui_gallery.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
#!/usr/bin/env node
import { mkdir } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { createServer } from 'node:http'
import { existsSync } from 'node:fs'
import { access, mkdir, readFile } from 'node:fs/promises'
import { dirname, extname, join, resolve, sep } from 'node:path'
import { spawn, spawnSync } from 'node:child_process'

const url = process.env.GOAGENT_UI_GALLERY_URL ?? 'http://localhost:5173/#/ui-gallery'
const configuredUrl = process.env.GOAGENT_UI_GALLERY_URL?.trim()
const rendererRoot = resolve('out/renderer')
const outDir = resolve(process.env.GOAGENT_UI_GALLERY_OUT ?? 'release-evidence/ui-gallery')
const skipBuild = process.argv.includes('--skip-build') || process.env.GOAGENT_UI_GALLERY_SKIP_BUILD === '1'
const captureTargets = [
['board', '.ui-gallery__panel--board'],
['teacher-card', '.ui-gallery__panel--teacher'],
Expand All @@ -14,6 +18,102 @@ const captureTargets = [
['settings-readiness', '.beta-acceptance-panel']
]

const mimeTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.webp': 'image/webp'
}

function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
...options
})
child.once('error', reject)
child.once('close', (code) => {
if (code === 0) resolvePromise()
else reject(new Error(`${command} ${args.join(' ')} exited with ${code ?? 'unknown status'}`))
})
})
}

async function ensureRendererBuild() {
if (!skipBuild) {
await run('pnpm', ['build'], { cwd: process.cwd(), env: process.env })
}
await access(join(rendererRoot, 'index.html')).catch(() => {
throw new Error('UI Gallery build is missing. Run `pnpm build` or omit `--skip-build`.')
})
}

function safeRendererPath(pathname) {
const relativePath = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '')
const candidate = resolve(rendererRoot, relativePath)
return candidate === rendererRoot || candidate.startsWith(`${rendererRoot}${sep}`) ? candidate : null
}

async function startRendererServer() {
await ensureRendererBuild()
const server = createServer(async (request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://127.0.0.1').pathname)
const requestedPath = safeRendererPath(pathname)
const fallbackPath = join(rendererRoot, 'index.html')
let filePath = requestedPath ?? fallbackPath
let body
try {
body = await readFile(filePath)
} catch {
filePath = fallbackPath
body = await readFile(filePath)
}
response.writeHead(200, {
'Cache-Control': 'no-store',
'Content-Type': mimeTypes[extname(filePath).toLowerCase()] ?? 'application/octet-stream'
})
response.end(body)
} catch (error) {
response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
response.end(String(error))
}
})
await new Promise((resolvePromise, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolvePromise)
})
const address = server.address()
if (!address || typeof address === 'string') {
server.close()
throw new Error('Unable to start the local UI Gallery server.')
}
return {
server,
url: `http://127.0.0.1:${address.port}/#/ui-gallery`
}
}

function systemBrowserPath() {
const candidates = [
process.env.GOAGENT_BROWSER_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
process.env.PROGRAMFILES ? join(process.env.PROGRAMFILES, 'Google', 'Chrome', 'Application', 'chrome.exe') : '',
process.env['PROGRAMFILES(X86)'] ? join(process.env['PROGRAMFILES(X86)'], 'Microsoft', 'Edge', 'Application', 'msedge.exe') : ''
].filter(Boolean)
return candidates.find((candidate) => existsSync(candidate)) ?? ''
}

async function loadPlaywright() {
try {
return await import('playwright')
Expand All @@ -22,15 +122,15 @@ async function loadPlaywright() {
}
}

async function captureWithCliFallback() {
async function captureWithCliFallback(url) {
await mkdir(outDir, { recursive: true })
const whichResult = spawnSync('npx', ['--yes', '-p', 'playwright', 'which', 'playwright'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit']
})
const playwrightBin = whichResult.stdout.trim()
if (whichResult.status !== 0 || !playwrightBin) {
throw new Error('Playwright package is not installed and npx could not locate it. Run pnpm dev, then capture this route manually: ' + url)
throw new Error('Playwright package is unavailable and npx could not prepare it. Open this self-hosted route manually: ' + url)
}
const playwrightNodeModules = dirname(dirname(playwrightBin))
const fallbackScript = `
Expand All @@ -46,23 +146,30 @@ async function captureWithCliFallback() {
const targets = ${JSON.stringify(captureTargets)}

await mkdir(outDir, { recursive: true })
const browser = await chromium.launch()
const browserPath = process.env.GOAGENT_BROWSER_PATH || undefined
const browser = await chromium.launch({ headless: true, executablePath: browserPath })
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 })
await page.goto(url, { waitUntil: 'networkidle' })
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.locator('.ui-gallery').waitFor({ state: 'visible' })
await page.screenshot({ path: join(outDir, 'ui-gallery-overview.png'), fullPage: true })
for (const [name, selector] of targets) {
const locator = page.locator(selector).first()
if (await locator.count()) {
await locator.screenshot({ path: join(outDir, name + '.png') })
}
}
const bindButton = page.getByRole('button', { name: '打开 SGF 绑定弹窗' })
if (await bindButton.count()) {
await bindButton.click()
await page.locator('.student-dialog').screenshot({ path: join(outDir, 'student-bind-dialog.png') })
}
await browser.close()
})().catch((error) => {
console.error(error.message)
process.exit(1)
})
`
const result = spawnSync('npx', [
await run('npx', [
'--yes',
'-p',
'playwright',
Expand All @@ -74,27 +181,22 @@ async function captureWithCliFallback() {
...process.env,
GOAGENT_CAPTURE_URL: url,
GOAGENT_CAPTURE_OUT: outDir,
GOAGENT_BROWSER_PATH: systemBrowserPath(),
PLAYWRIGHT_NODE_MODULES: playwrightNodeModules
},
stdio: 'inherit'
})
if (result.status !== 0) {
throw new Error('Playwright package is not installed and npx Playwright capture failed. Run pnpm dev, then capture this route manually: ' + url)
}
console.log(`Captured UI Gallery screenshots in ${outDir}`)
}

async function capture() {
const playwright = await loadPlaywright()
if (!playwright) {
await captureWithCliFallback()
return
}
async function capturePage(playwright, url) {
const browserPath = systemBrowserPath()
const { chromium } = playwright
await mkdir(outDir, { recursive: true })
const browser = await chromium.launch()
const browser = await chromium.launch({ headless: true, executablePath: browserPath || undefined })
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 })
await page.goto(url, { waitUntil: 'networkidle' })
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.locator('.ui-gallery').waitFor({ state: 'visible' })
await page.screenshot({ path: join(outDir, 'ui-gallery-overview.png'), fullPage: true })

for (const [name, selector] of captureTargets) {
Expand All @@ -114,6 +216,21 @@ async function capture() {
console.log(`Captured UI Gallery screenshots in ${outDir}`)
}

async function capture() {
const hosted = configuredUrl ? null : await startRendererServer()
const url = configuredUrl || hosted?.url
if (!url) throw new Error('Unable to resolve the UI Gallery URL.')
const playwright = await loadPlaywright()
try {
if (playwright) await capturePage(playwright, url)
else await captureWithCliFallback(url)
} finally {
if (hosted) {
await new Promise((resolvePromise) => hosted.server.close(resolvePromise))
}
}
}

capture().catch((error) => {
console.error(error.message)
process.exit(1)
Expand Down
5 changes: 3 additions & 2 deletions src/main/services/katago.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ interface ActiveKataGoProcess {

const QUICK_ANALYSIS_FAST_VISITS = 24
const QUICK_ANALYSIS_MAX_SWEEP_VISITS = 80
const QUICK_ANALYSIS_REFINE_VISITS = 120
const QUICK_ANALYSIS_REFINE_VISITS = 180
const QUICK_ANALYSIS_REFINE_TOP_N = 10
const QUICK_ANALYSIS_REFINE_MIN_LOSS = 4
const QUICK_ANALYSIS_WIDE_ROOT_NOISE = 0
Expand Down Expand Up @@ -1425,7 +1425,8 @@ export async function analyzeGameQuick(
winrateLoss,
scoreLoss
},
judgement: judgement(winrateLoss, scoreLoss)
judgement: judgement(winrateLoss, scoreLoss),
analysisQuality: buildAnalysisQuality(moveNumber, currentMove, beforeTopMoves, forcedActual)
}
analysis.moveClassification = classifyMoveAnalysis(analysis)
analysis.pvConfidence = buildPvConfidenceReport(analysis)
Expand Down
19 changes: 5 additions & 14 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { KataGoAssetsPanel } from './features/settings/KataGoAssetsPanel'
import { TeacherSpeechControls } from './features/tts/TeacherSpeechControls'
import { TtsSettingsPanel } from './features/tts/TtsSettingsPanel'
import { TeacherComposerPro } from './features/teacher/TeacherComposerPro'
import { analysisDisplaySeverity, isVerifiedTimelineIssue } from './features/timeline/analysisTrust'
import {
createUiTranslator,
humanizeUiError,
Expand Down Expand Up @@ -458,11 +459,11 @@ const LIVE_ANALYSIS_TIME_LIMIT_MS = 150_000
const LIVE_ANALYSIS_REPORT_INTERVAL_SECONDS = 0.2
const QUICK_GRAPH_FAST_VISITS = 24
const QUICK_GRAPH_FAST_VISITS_STRONG = 48
const QUICK_GRAPH_REFINE_VISITS = 120
const QUICK_GRAPH_REFINE_VISITS = 180
const QUICK_GRAPH_REFINE_TOP_N = 8
const LIBRARY_PAGE_SIZE = 10
const TIMELINE_ISSUE_MIN_LOSS = 1
const ANALYSIS_CACHE_SCHEMA_VERSION = 'v5-komi-and-stable-quick-winrate'
const ANALYSIS_CACHE_SCHEMA_VERSION = 'v6-evidence-aware-issues'
const ANALYSIS_CACHE_PREFIX = `goagent.analysisCache.${ANALYSIS_CACHE_SCHEMA_VERSION}.`
const ANALYSIS_CACHE_INDEX_KEY = `goagent.analysisCache.${ANALYSIS_CACHE_SCHEMA_VERSION}.index`
const ANALYSIS_CACHE_MAX_GAMES = 8
Expand Down Expand Up @@ -761,7 +762,7 @@ function timelineIssuesFromEvaluations(
return []
}
const loss = normalizeLossPercent(item.playedMove?.winrateLoss)
if (loss < TIMELINE_ISSUE_MIN_LOSS) {
if (loss < TIMELINE_ISSUE_MIN_LOSS || !isVerifiedTimelineIssue(item, TIMELINE_ISSUE_MIN_LOSS)) {
return []
}
const playedMove = item.playedMove?.move ?? item.currentMove?.gtp ?? record?.moves[item.moveNumber - 1]?.gtp ?? ''
Expand Down Expand Up @@ -5659,17 +5660,7 @@ function formatSearchSpeed(visitsPerSecond: number): string {
}

function evaluationSeverity(item: KataGoMoveAnalysis): 'quiet' | 'inaccuracy' | 'mistake' | 'blunder' {
const winrateLoss = normalizeLossPercent(item.playedMove?.winrateLoss)
if (item.judgement === 'blunder' || winrateLoss >= 18) {
return 'blunder'
}
if (item.judgement === 'mistake' || winrateLoss >= 10) {
return 'mistake'
}
if (item.judgement === 'inaccuracy' || winrateLoss >= 4) {
return 'inaccuracy'
}
return 'quiet'
return analysisDisplaySeverity(item)
}

function formatIssueLoss(loss: number): string {
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/features/board/GoBoardV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { GameRecord, KataGoMoveAnalysis } from '@main/lib/types'
import type { UiTranslator } from '../../i18n'
import {
candidateVariationMoves,
candidatePerspectiveColor,
getBoardSize,
moveToColor,
normalizeWinrate,
Expand Down Expand Up @@ -169,14 +170,16 @@ function svgCursorPoint(event: BoardHoverEvent): { x: number; y: number } | null
function CandidateMark({
candidate,
boardSize,
perspectiveColor,
active = false
}: {
candidate: RenderCandidate
boardSize: number
perspectiveColor: 'B' | 'W'
active?: boolean
}): ReactElement {
const p = xy(candidate, boardSize)
const className = `ks-candidate ks-candidate--${candidate.emphasis} ks-candidate--rank-${candidate.rank}${active ? ' ks-candidate--active' : ''}`
const className = `ks-candidate ks-candidate--${candidate.emphasis} ks-candidate--rank-${candidate.rank} ks-candidate-perspective--${perspectiveColor}${active ? ' ks-candidate--active' : ''}`
const scale = candidate.emphasis === 'primary' ? 1.02 : candidate.emphasis === 'secondary' ? 0.96 : 0.9
const winrate = formatCandidateWinrate(candidate)
const visits = formatCandidateVisits(candidate)
Expand Down Expand Up @@ -460,6 +463,7 @@ export function GoBoardV2({
const boardSize = getBoardSize(record)
const stones = useMemo(() => renderStones(record, moveNumber), [record, moveNumber])
const candidates = useMemo(() => renderCandidates(analysis, boardSize), [analysis, boardSize])
const candidatePerspective = useMemo(() => candidatePerspectiveColor(analysis), [analysis])
const playedMove = useMemo(() => renderPlayedMove(analysis, boardSize), [analysis, boardSize])
const strandedStones = useMemo(() => territoryStrandedStones(stones, territoryJudgement), [stones, territoryJudgement])
const strandedStoneKeys = useMemo(() => new Set(strandedStones.map((stone) => `${stone.x},${stone.y}`)), [strandedStones])
Expand Down Expand Up @@ -708,6 +712,7 @@ export function GoBoardV2({
key={`${candidate.rank}-${candidate.label}`}
candidate={candidate}
boardSize={boardSize}
perspectiveColor={candidatePerspective}
active={sameCandidate(activeCandidate?.renderCandidate, candidate)}
/>
))}
Expand Down
Loading
Loading