diff --git a/src/main/lib/moveRange.ts b/src/main/lib/moveRange.ts new file mode 100644 index 0000000..7d5f9a8 --- /dev/null +++ b/src/main/lib/moveRange.ts @@ -0,0 +1,90 @@ +export interface ParsedMoveRange { + start: number + end: number +} + +// Contextual patterns: require language markers (手/수/moves/from/to/through) +const CONTEXTUAL_MOVE_RANGE_PATTERN = new RegExp( + [ + // Chinese: 第100手到第200手, 100手至200手 + '(?:第\\s*)?(\\d+)\\s*手\\s*(?:到|至)\\s*(?:第\\s*)?(\\d+)\\s*手', + // Japanese: 100手から200手, 第100手から第200手, 100手〜200手 + '(?:第\\s*)?(\\d+)\\s*手\\s*(?:から|〜)\\s*(?:第\\s*)?(\\d+)\\s*手', + // Korean: 100수부터200수, 100수~200수, 100수-200수 + '(\\d+)\\s*수\\s*(?:부터|~|-)\\s*(\\d+)\\s*수', + // English: moves 100-200 (requires "moves" prefix) + 'moves?\\s+(\\d+)\\s*(?:[-~—−])\\s*(\\d+)', + // English: from move 100 to 200, from move 100 to move 200 + 'from\\s+move\\s+(\\d+)\\s+to\\s+(?:move\\s+)?(\\d+)', + // English: move 100 to move 200, moves 100 to 200 + 'moves?\\s+(\\d+)\\s+to\\s+(?:move\\s+)?(\\d+)', + // English: moves 100 through 200 + 'moves?\\s+(\\d+)\\s+(?:through|thru)\\s+(\\d+)' + ].join('|'), + 'i' +) + +// Bare numeric range: only accepted when the whole prompt looks like a short command +const BARE_NUMERIC_RANGE_PATTERN = /(\d+)\s*[-~—−]\s*(\d+)/ +const BARE_FROM_TO_PATTERN = /from\s+(\d+)\s+to\s+(\d+)/ + +function looksLikeBareRangeCommand(text: string): boolean { + const t = text.trim() + return /^(?:分析|看|讲|复盘|review|analyze)?\s*\d+\s*[-~—−]\s*\d+\s*(?:手|moves?)?\s*$/i.test(t) || + /^(?:review|analyze)?\s*from\s+\d+\s+to\s+\d+\s*(?:moves?)?\s*$/i.test(t) +} + +function extractMatch(match: RegExpMatchArray): { a: number; b: number } | null { + const aRaw = match[1] ?? match[3] ?? match[5] ?? match[7] ?? match[9] ?? match[11] ?? match[13] + const bRaw = match[2] ?? match[4] ?? match[6] ?? match[8] ?? match[10] ?? match[12] ?? match[14] + if (!aRaw || !bRaw) return null + const a = Number(aRaw) + const b = Number(bRaw) + if (!Number.isInteger(a) || !Number.isInteger(b)) return null + return { a, b } +} + +export function parseMoveRangeFromPrompt(text: string, totalMoves?: number): ParsedMoveRange | null { + // 1. Try contextual patterns first (always safe) + const contextualMatch = text.match(CONTEXTUAL_MOVE_RANGE_PATTERN) + if (contextualMatch) { + const extracted = extractMatch(contextualMatch) + if (extracted) { + const start = Math.min(extracted.a, extracted.b) + const end = Math.max(extracted.a, extracted.b) + if (start < 1 || end <= start) return null + if (totalMoves !== undefined && end > totalMoves) return null + return { start, end } + } + } + + // 2. Bare numeric/from-to range: only when prompt is a short command-like phrase + if (looksLikeBareRangeCommand(text)) { + const fromToMatch = text.match(BARE_FROM_TO_PATTERN) + if (fromToMatch) { + const a = Number(fromToMatch[1]) + const b = Number(fromToMatch[2]) + if (Number.isInteger(a) && Number.isInteger(b)) { + const start = Math.min(a, b) + const end = Math.max(a, b) + if (start < 1 || end <= start) return null + if (totalMoves !== undefined && end > totalMoves) return null + return { start, end } + } + } + const bareMatch = text.match(BARE_NUMERIC_RANGE_PATTERN) + if (bareMatch) { + const a = Number(bareMatch[1]) + const b = Number(bareMatch[2]) + if (Number.isInteger(a) && Number.isInteger(b)) { + const start = Math.min(a, b) + const end = Math.max(a, b) + if (start < 1 || end <= start) return null + if (totalMoves !== undefined && end > totalMoves) return null + return { start, end } + } + } + } + + return null +} diff --git a/src/main/lib/types.ts b/src/main/lib/types.ts index 467cbc0..449632b 100644 --- a/src/main/lib/types.ts +++ b/src/main/lib/types.ts @@ -473,10 +473,18 @@ export interface StructuredTeacherResult { } } +import type { MoveRangeProgression } from '@shared/moveRangeAnalysis' +export type { MoveRangeProgression } + export interface MoveRangeKeyMoveSummary { moveNumber: number + moveColor?: 'B' | 'W' playedMove?: string bestMove?: string + blackWinrateBefore?: number + blackScoreLeadBefore?: number + blackWinrateAfter?: number + blackScoreLeadAfter?: number winrateLoss: number scoreLoss: number judgement?: string @@ -490,6 +498,7 @@ export interface MoveRangeReviewSummary { keyMoves: MoveRangeKeyMoveSummary[] omittedMoves: number analysisMethod: string + progression?: MoveRangeProgression } export interface TeacherRunRequest { diff --git a/src/main/services/go/boardTextRender.ts b/src/main/services/go/boardTextRender.ts new file mode 100644 index 0000000..e4a9c00 --- /dev/null +++ b/src/main/services/go/boardTextRender.ts @@ -0,0 +1,58 @@ +import type { GameMove, StoneColor } from '../../lib/types' +import { buildBoardState, boardStateToSnapshot, coordToGtp, gtpToCoord } from './boardState' + +interface BoardTextRecord { + boardSize: number + moves: GameMove[] + initialStones?: Array<{ color: StoneColor; row: number; col: number; point: string }> +} + +export function renderBoardText( + record: BoardTextRecord, + uptoMoveNumber: number +): string { + const boardSize = record.boardSize + const safeMoveNumber = Math.max(0, Math.min(Math.trunc(uptoMoveNumber), record.moves.length)) + const boardState = buildBoardState({ + boardSize, + moves: record.moves, + uptoMoveNumber: safeMoveNumber, + initialStones: record.initialStones + }) + const snapshot = boardStateToSnapshot(boardState) + const stoneMap = new Map() + for (const s of snapshot) { + const coord = gtpToCoord(s.point, boardSize) + if (coord) stoneMap.set(`${coord.row},${coord.col}`, s.color) + } + const lastMove = safeMoveNumber > 0 ? record.moves[safeMoveNumber - 1] : undefined + let lastMoveKey = '' + if (lastMove && !lastMove.pass && lastMove.row !== null && lastMove.col !== null) { + lastMoveKey = `${lastMove.row},${lastMove.col}` + } + const columns = Array.from({ length: boardSize }, (_, col) => + coordToGtp(0, col, boardSize).replace(/\d+$/, '') + ) + const lines: string[] = [] + lines.push(' ' + columns.join(' ')) + for (let row = 0; row < boardSize; row++) { + const rowNum = boardSize - row + const cells: string[] = [] + for (let col = 0; col < boardSize; col++) { + const key = `${row},${col}` + const color = stoneMap.get(key) + const isLastMove = `${row},${col}` === lastMoveKey + if (isLastMove) { + cells.push(color === 'B' ? '◉' : '◎') + } else if (color === 'B') { + cells.push('●') + } else if (color === 'W') { + cells.push('○') + } else { + cells.push('·') + } + } + lines.push(`${String(rowNum).padStart(2)} ${cells.join(' ')}`) + } + return lines.join('\n') +} diff --git a/src/main/services/katago.ts b/src/main/services/katago.ts index 3f2a085..42a4b55 100644 --- a/src/main/services/katago.ts +++ b/src/main/services/katago.ts @@ -602,6 +602,56 @@ function buildMoveAnalysis( } } +export async function analyzeMoveRange( + gameId: string, + startMove: number, + endMove: number, + maxVisits = 200 +): Promise { + const indexedGame = findGame(gameId) + if (!indexedGame) { + throw new Error(`找不到棋谱: ${gameId}`) + } + const game = await ensureFoxGameDownloaded(indexedGame) + const record = readGameRecord(game) + if (startMove < 1 || endMove > record.moves.length || endMove <= startMove) { + throw new Error(`区间无效: startMove=${startMove}, endMove=${endMove}, 总手数=${record.moves.length}`) + } + const komi = normalizeKomi(record.komi) + const afterVisits = Math.max(24, Math.floor(maxVisits * 0.55)) + const queries: AnalysisQuery[] = [] + + for (let moveNumber = startMove; moveNumber <= endMove; moveNumber++) { + const currentMove = moveNumber > 0 ? record.moves[moveNumber - 1] : undefined + const beforeMoves = record.moves.slice(0, Math.max(0, moveNumber - 1)) + const afterMoves = record.moves.slice(0, Math.max(0, moveNumber)) + const beforeId = `${gameId}-range-before-${moveNumber}` + const afterId = `${gameId}-range-after-${moveNumber}` + const actualId = `${gameId}-range-actual-${moveNumber}` + + queries.push({ id: beforeId, moves: moveHistory(beforeMoves), boardSize: record.boardSize, komi, maxVisits }) + queries.push({ id: afterId, moves: moveHistory(afterMoves), boardSize: record.boardSize, komi, maxVisits: afterVisits }) + + const actualQuery = forcePlayedMoveQuery(actualId, beforeMoves, currentMove, record.boardSize, komi, maxVisits) + if (actualQuery) { + queries.push(actualQuery) + } + } + + const responses = await queryKataGoBatch(queries) + const results: KataGoMoveAnalysis[] = [] + + for (let moveNumber = startMove; moveNumber <= endMove; moveNumber++) { + const beforeResponse = responses.get(`${gameId}-range-before-${moveNumber}`) + const afterResponse = responses.get(`${gameId}-range-after-${moveNumber}`) + if (!beforeResponse || !afterResponse) continue + const currentMove = moveNumber > 0 ? record.moves[moveNumber - 1] : undefined + results.push(buildMoveAnalysis(gameId, moveNumber, record.boardSize, currentMove, beforeResponse, afterResponse, responses.get(`${gameId}-range-actual-${moveNumber}`))) + } + + return results +} + export async function analyzePositionWithProgress( gameId: string, moveNumber: number, diff --git a/src/main/services/teacher/moveRangeReview.ts b/src/main/services/teacher/moveRangeReview.ts index 1cbaa83..27d0063 100644 --- a/src/main/services/teacher/moveRangeReview.ts +++ b/src/main/services/teacher/moveRangeReview.ts @@ -1,5 +1,6 @@ import type { KataGoMoveAnalysis, MoveRangeReviewSummary } from '@main/lib/types' import { MOVE_RANGE_KEY_MOVE_LIMIT, type ParsedMoveRange, selectKeyMoveNumbers } from '@shared/moveRange' +import { buildMoveRangeProgression, type MoveRangeProgressionInput } from '@shared/moveRangeAnalysis' function round(value: number | undefined, digits = 2): number { if (typeof value !== 'number' || !Number.isFinite(value)) return 0 @@ -32,8 +33,13 @@ export function summarizeMoveRangeAnalyses( .filter((analysis): analysis is KataGoMoveAnalysis => Boolean(analysis)) .map((analysis) => ({ moveNumber: analysis.moveNumber, + moveColor: analysis.currentMove?.color, playedMove: analysis.playedMove?.move ?? analysis.currentMove?.gtp, bestMove: analysis.before.topMoves[0]?.move, + blackWinrateBefore: analysis.before.winrate, + blackScoreLeadBefore: analysis.before.scoreLead, + blackWinrateAfter: analysis.after.winrate, + blackScoreLeadAfter: analysis.after.scoreLead, winrateLoss: round(analysis.playedMove?.winrateLoss ?? 0, 2), scoreLoss: round(analysis.playedMove?.scoreLoss ?? 0, 2), judgement: analysis.judgement, @@ -43,13 +49,28 @@ export function summarizeMoveRangeAnalyses( analysis.tacticalSignals?.[0]?.type ? `tactical:${analysis.tacticalSignals[0].type}` : '' ].filter(Boolean) })) + + const progressionInputs: MoveRangeProgressionInput[] = sorted.map((analysis) => ({ + moveNumber: analysis.moveNumber, + blackWinrateBefore: analysis.before.winrate, + blackScoreLeadBefore: analysis.before.scoreLead, + blackWinrateAfter: analysis.after.winrate, + blackScoreLeadAfter: analysis.after.scoreLead, + winrateLoss: analysis.playedMove?.winrateLoss + })) + const progression = buildMoveRangeProgression(progressionInputs, { + expectedStart: range.start, + expectedEnd: range.end + }) ?? undefined + return { start: range.start, end: range.end, totalMoves: sorted.length || range.end - range.start + 1, keyMoves, omittedMoves: Math.max(0, (range.end - range.start + 1) - keyMoves.length), - analysisMethod: 'range-cache-or-quick-sweep, then key-move-focused teacher review' + analysisMethod: 'range-cache-or-quick-sweep, then key-move-focused teacher review', + progression } } @@ -58,19 +79,56 @@ export function formatMoveRangeSummaryForPrompt(summary: MoveRangeReviewSummary const lines = [ `区间:第 ${summary.start}-${summary.end} 手,共 ${summary.totalMoves} 手。`, `分析方法:${summary.analysisMethod}`, - `未逐手展开的手数:${summary.omittedMoves}`, - '关键手:' + `未逐手展开的手数:${summary.omittedMoves}` ] + + const p = summary.progression + if (p) { + const coverageOk = p.startsAtRequestedStart && p.endsAtRequestedEnd + const header = coverageOk ? '走势概要:' : '已分析数据走势:' + lines.push('', header) + + if (typeof p.blackWinrateStart === 'number' && typeof p.blackWinrateEnd === 'number') { + const change = typeof p.totalBlackWinrateChange === 'number' + ? `(${p.totalBlackWinrateChange >= 0 ? '+' : ''}${round(p.totalBlackWinrateChange, 1)}%)` + : '' + lines.push(`黑棋胜率:${round(p.blackWinrateStart, 1)}% → ${round(p.blackWinrateEnd, 1)}%${change}`) + } + if (typeof p.blackScoreLeadStart === 'number' && typeof p.blackScoreLeadEnd === 'number') { + const change = typeof p.totalBlackScoreLeadChange === 'number' + ? `(${p.totalBlackScoreLeadChange >= 0 ? '+' : ''}${round(p.totalBlackScoreLeadChange, 1)})` + : '' + lines.push(`目差:${round(p.blackScoreLeadStart, 1)} → ${round(p.blackScoreLeadEnd, 1)}${change}`) + } + if (typeof p.maxSingleMoveBlackWinrateSwing === 'number') { + lines.push(`单手最大胜率波动:${round(p.maxSingleMoveBlackWinrateSwing, 1)}%`) + } + if (p.swingMoves.length) { + lines.push(`关键波动手:${p.swingMoves.map((m: { moveNumber: number; winrateLoss: number }) => `第 ${m.moveNumber} 手(loss ${round(m.winrateLoss, 1)}%)`).join('、')}`) + } + } + + lines.push('', '关键手:') for (const move of summary.keyMoves) { - lines.push([ + const parts = [ `- 第 ${move.moveNumber} 手`, + move.moveColor ? `(${move.moveColor})` : '', move.playedMove ? `实战 ${move.playedMove}` : '', - move.bestMove ? `首选 ${move.bestMove}` : '', - `胜率损失 ${round(move.winrateLoss, 1)}%`, - `目差损失 ${round(move.scoreLoss, 1)}`, - move.judgement ? `判断 ${move.judgement}` : '', - move.evidenceRefs.length ? `证据 ${move.evidenceRefs.join(', ')}` : '' - ].filter(Boolean).join(',')) + move.bestMove ? `首选 ${move.bestMove}` : '' + ] + if (typeof move.blackWinrateBefore === 'number' && typeof move.blackWinrateAfter === 'number') { + parts.push(`胜率 ${round(move.blackWinrateBefore, 1)}%→${round(move.blackWinrateAfter, 1)}%(损失 ${round(move.winrateLoss, 1)}%)`) + } else { + parts.push(`胜率损失 ${round(move.winrateLoss, 1)}%`) + } + if (typeof move.blackScoreLeadBefore === 'number' && typeof move.blackScoreLeadAfter === 'number') { + parts.push(`目差 ${round(move.blackScoreLeadBefore, 1)}→${round(move.blackScoreLeadAfter, 1)}(损失 ${round(move.scoreLoss, 1)})`) + } else { + parts.push(`目差损失 ${round(move.scoreLoss, 1)}`) + } + if (move.judgement) parts.push(`判断 ${move.judgement}`) + if (move.evidenceRefs.length) parts.push(`证据 ${move.evidenceRefs.join(', ')}`) + lines.push(parts.filter(Boolean).join(',')) } return lines.join('\n') } diff --git a/src/main/services/teacherAgent.ts b/src/main/services/teacherAgent.ts index ef520b5..a7b3251 100644 --- a/src/main/services/teacherAgent.ts +++ b/src/main/services/teacherAgent.ts @@ -6,6 +6,7 @@ import { getGames, getSettings, replaceSettings, reportsDir } from '@main/lib/st import type { CoachUserLevel, GameMove, + GameRecord, KataGoMoveAnalysis, KnowledgeMatch, KnowledgePacket, @@ -27,7 +28,8 @@ import { MOVE_RANGE_KEY_MOVE_LIMIT, MOVE_RANGE_MAX_MOVES, parseMoveRangeFromProm import { formatMoveRangeSummaryForPrompt, selectMoveNumbersForRangeRefine } from './teacher/moveRangeReview' import { searchKnowledge, searchKnowledgeMatches } from './knowledge' import { recommendedProblemsFromMatches, type BoardSnapshotStone, type LocalWindow } from './knowledge/matchEngine' -import { buildBoardState, boardStateToSnapshot } from './go/boardState' +import { buildBoardState, boardStateToSnapshot, coordToGtp, gtpToCoord } from './go/boardState' +import { renderBoardText } from './go/boardTextRender' import { detectTacticalSignals } from './knowledge/tacticalDetectors' import { readGameRecord } from './sgf' import { ensureFoxGameDownloaded } from './fox' @@ -138,7 +140,7 @@ function findGamesForStudent(studentName: string, count: number): LibraryGame[] return (matched.length > 0 ? matched : games).slice(0, count) } -function gtpToCoord(point: string, boardSize: number): { row: number; col: number } | null { +function gtpToCoordLocal(point: string, boardSize: number): { row: number; col: number } | null { const match = point.trim().toUpperCase().match(/^([A-HJ-T])(\d{1,2})$/) if (!match) return null const letters = 'ABCDEFGHJKLMNOPQRST' @@ -148,7 +150,7 @@ function gtpToCoord(point: string, boardSize: number): { row: number; col: numbe return { row: boardSize - number, col } } -function coordToGtp(row: number, col: number, boardSize: number): string { +function coordToGtpLocal(row: number, col: number, boardSize: number): string { const letters = 'ABCDEFGHJKLMNOPQRST' return `${letters[col]}${boardSize - row}` } @@ -196,7 +198,7 @@ function buildBoardSnapshot(moves: GameMove[], uptoMoveNumber: number, boardSize const board = new Map() for (const move of moves.slice(0, Math.max(0, uptoMoveNumber))) { if (move.pass) continue - const coord = move.row !== null && move.col !== null ? { row: move.row, col: move.col } : gtpToCoord(move.gtp, boardSize) + const coord = move.row !== null && move.col !== null ? { row: move.row, col: move.col } : gtpToCoordLocal(move.gtp, boardSize) if (!coord) continue const key = coordKey(coord.row, coord.col) board.set(key, move.color) @@ -215,19 +217,19 @@ function buildBoardSnapshot(moves: GameMove[], uptoMoveNumber: number, boardSize } return [...board.entries()].map(([key, color]) => { const [row, col] = key.split(',').map(Number) - return { color, point: coordToGtp(row, col, boardSize) } + return { color, point: coordToGtpLocal(row, col, boardSize) } }) } function buildLocalWindows(snapshot: BoardSnapshotStone[], anchors: Array, boardSize: number): LocalWindow[] { return [...new Set(anchors.filter(Boolean) as string[])] - .filter((anchor) => gtpToCoord(anchor, boardSize)) + .filter((anchor) => gtpToCoordLocal(anchor, boardSize)) .map((anchor) => { - const anchorPoint = gtpToCoord(anchor, boardSize)! + const anchorPoint = gtpToCoordLocal(anchor, boardSize)! return { anchor, stones: snapshot.filter((stone) => { - const point = gtpToCoord(stone.point, boardSize) + const point = gtpToCoordLocal(stone.point, boardSize) if (!point) return false return Math.max(Math.abs(point.row - anchorPoint.row), Math.abs(point.col - anchorPoint.col)) <= 4 }) @@ -529,7 +531,7 @@ function initialAgentUserMessage(state: TeacherAgentSessionState): ChatMessage { const text = [ '任务说明:请根据 intent 完成用户请求。', '如果 intent 是 current-move,请先观察随消息附带的棋盘图片,再调用 KataGo 和知识库工具核对事实。', - '如果 intent 是 move-range,请依次观察附带的区间关键手棋盘图片,先概括区间胜率/目差走势,再重点讲解 top-loss 关键手。', + '如果 intent 是 move-range,请依次观察附带的区间关键手棋盘图片和走势概要。讲解时先概括区间整体走势(谁占优、转折在哪里),再重点讲解 top-loss 关键手。讲解关键手时,如有定式、死活、手筋或常见错误相关特征,请调用 knowledge.searchLocal 核对。需要查看已有图片之外的某手局面时,可调用 board.renderRangePosition 获取文本棋盘表示(每次最多5手)。', '当前手讲解要按工具返回的 teachingDensity 掌握详略:常规定式少讲;定式分支或相似型列关键变化;中盘战、攻杀、转换要讲目的、对方应手、后续变化和实战评价。', 'boardImageAttached=true 表示本轮用户消息已附棋盘图,请把图片中的棋形、厚薄、急所和全局方向作为局面判断依据。', 'moveRangeSummary 是 renderer/cache 预先提取的区间关键手摘要;如证据不足,可调用 katago.analyzeMoveRangeKeyMoves 精读这些关键手。', @@ -601,6 +603,19 @@ function compactAnalysis(analysis: KataGoMoveAnalysis): JsonObject { } } +function numberArrayInput(input: JsonObject, key: string): number[] { + const value = input[key] + if (!Array.isArray(value)) return [] + return value + .map((item) => { + if (typeof item === 'number') return item + if (typeof item === 'string' && item.trim() !== '') return Number(item) + return NaN + }) + .filter(Number.isFinite) + .map((item) => Math.trunc(item)) +} + async function knowledgeBundleForState(state: TeacherAgentSessionState, input: JsonObject): Promise<{ knowledge: KnowledgePacket[] knowledgeMatches: KnowledgeMatch[] @@ -909,6 +924,62 @@ function createTeacherAgentTools(state: TeacherAgentSessionState): TeacherAgentT } } }, + { + apiName: 'board_renderRangePosition', + canonicalName: 'board.renderRangePosition', + label: '渲染区间关键手棋盘', + description: '渲染区间内指定手数的棋盘局面文本表示,用于查看已有图片之外的关键手棋形。每次最多查询5手。', + parameters: schema({ + moveNumbers: { + type: 'array', + items: { type: 'number' }, + description: '需要渲染棋盘的手数列表(最多5个,应为区间内且已有图片未覆盖的手)' + } + }), + execute: async (input) => { + const record = await ensureSessionRecord(state) + if (!record) throw new Error('没有棋谱数据。') + + const rawNumbers = numberArrayInput(input, 'moveNumbers') + const uniqueMoveNumbers = [...new Set(rawNumbers)] + .filter((mn) => mn >= 1 && mn <= record.moves.length) + + const range = state.request.moveRange + const moveNumbers = range + ? [ + ...uniqueMoveNumbers.filter((mn) => mn >= range.start && mn <= range.end), + ...uniqueMoveNumbers.filter((mn) => mn < range.start || mn > range.end) + ].slice(0, 5) + : uniqueMoveNumbers.slice(0, 5) + + if (moveNumbers.length === 0) { + return { positions: [], note: '没有有效手数;请提供 1 到棋谱总手数之间的 moveNumbers。' } + } + + return { + positions: moveNumbers.map((mn) => { + const boardState = buildBoardState({ + boardSize: record.boardSize, + moves: record.moves, + uptoMoveNumber: mn, + initialStones: record.initialStones + }) + const snapshot = boardStateToSnapshot(boardState) + const move = record.moves[mn - 1] + return { + moveNumber: mn, + moveColor: move?.color, + playedMove: move?.gtp, + board: renderBoardText(record, mn), + stoneCount: { + black: snapshot.filter((s) => s.color === 'B').length, + white: snapshot.filter((s) => s.color === 'W').length + } + } + }) + } + } + }, { apiName: 'knowledge_searchLocal', canonicalName: 'knowledge.searchLocal', diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c4f844f..a23bcdd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -22,6 +22,7 @@ import type { TeacherRunResult } from '@main/lib/types' import { MOVE_RANGE_MAX_MOVES, describeMoveRange, parseMoveRangeFromPrompt, validateMoveRange } from '@shared/moveRange' +import { buildMoveRangeProgression } from '@shared/moveRangeAnalysis' import lizzieBlackStoneUrl from './assets/lizzie/black.png' import lizzieBoardUrl from './assets/lizzie/board.png' import lizzieWhiteStoneUrl from './assets/lizzie/white.png' @@ -1167,8 +1168,13 @@ export function App(): ReactElement { .filter((item): item is KataGoMoveAnalysis => Boolean(item)) .map((item) => ({ moveNumber: item.moveNumber, + moveColor: item.currentMove?.color, playedMove: item.playedMove?.move ?? item.currentMove?.gtp, bestMove: item.before.topMoves[0]?.move, + blackWinrateBefore: item.before.winrate, + blackScoreLeadBefore: item.before.scoreLead, + blackWinrateAfter: item.after.winrate, + blackScoreLeadAfter: item.after.scoreLead, winrateLoss: Math.round((item.playedMove?.winrateLoss ?? 0) * 100) / 100, scoreLoss: Math.round((item.playedMove?.scoreLoss ?? 0) * 100) / 100, judgement: item.judgement, @@ -1178,13 +1184,25 @@ export function App(): ReactElement { item.tacticalSignals?.[0]?.type ? `tactical:${item.tacticalSignals[0].type}` : '' ].filter(Boolean) })) + const progression = buildMoveRangeProgression( + sorted.map((item) => ({ + moveNumber: item.moveNumber, + blackWinrateBefore: item.before.winrate, + blackScoreLeadBefore: item.before.scoreLead, + blackWinrateAfter: item.after.winrate, + blackScoreLeadAfter: item.after.scoreLead, + winrateLoss: item.playedMove?.winrateLoss + })), + { expectedStart: rangeStart, expectedEnd: rangeEnd } + ) ?? undefined return { start: rangeStart, end: rangeEnd, totalMoves: rangeEnd - rangeStart + 1, keyMoves, omittedMoves: Math.max(0, rangeEnd - rangeStart + 1 - keyMoves.length), - analysisMethod: 'cached evaluations or quick sweep, then top-loss key-move review' + analysisMethod: 'cached evaluations or quick sweep, then top-loss key-move review', + progression } } diff --git a/src/shared/moveRange.ts b/src/shared/moveRange.ts index b6a85f7..542873a 100644 --- a/src/shared/moveRange.ts +++ b/src/shared/moveRange.ts @@ -117,8 +117,13 @@ export function parseMoveRangeFromPrompt( export interface MoveRangeKeyMoveSummary { moveNumber: number + moveColor?: 'B' | 'W' playedMove?: string bestMove?: string + blackWinrateBefore?: number + blackScoreLeadBefore?: number + blackWinrateAfter?: number + blackScoreLeadAfter?: number winrateLoss: number scoreLoss: number judgement?: string @@ -132,6 +137,7 @@ export interface MoveRangeSummaryLike { keyMoves: MoveRangeKeyMoveSummary[] omittedMoves: number analysisMethod: string + progression?: import('./moveRangeAnalysis').MoveRangeProgression } export function selectKeyMoveNumbers(summary: MoveRangeSummaryLike | undefined, fallbackRange: ParsedMoveRange | undefined, limit = MOVE_RANGE_KEY_MOVE_LIMIT): number[] { diff --git a/src/shared/moveRangeAnalysis.ts b/src/shared/moveRangeAnalysis.ts new file mode 100644 index 0000000..298b683 --- /dev/null +++ b/src/shared/moveRangeAnalysis.ts @@ -0,0 +1,93 @@ +export interface MoveRangeProgressionInput { + moveNumber: number + blackWinrateBefore?: number + blackScoreLeadBefore?: number + blackWinrateAfter?: number + blackScoreLeadAfter?: number + winrateLoss?: number +} + +export interface MoveRangeProgressionOptions { + expectedStart?: number + expectedEnd?: number +} + +export interface MoveRangeProgression { + blackWinrateStart?: number + blackWinrateEnd?: number + blackScoreLeadStart?: number + blackScoreLeadEnd?: number + totalBlackWinrateChange?: number + totalBlackScoreLeadChange?: number + maxSingleMoveBlackWinrateSwing?: number + swingMoves: Array<{ moveNumber: number; winrateLoss: number }> + startsAtRequestedStart?: boolean + endsAtRequestedEnd?: boolean +} + +export function buildMoveRangeProgression( + items: MoveRangeProgressionInput[], + options?: MoveRangeProgressionOptions +): MoveRangeProgression | null { + if (!items.length) return null + + const withAbsolute = items + .filter((item) => typeof item.blackWinrateBefore === 'number') + .sort((a, b) => a.moveNumber - b.moveNumber) + + if (!withAbsolute.length) return null + + const first = withAbsolute[0] + const last = withAbsolute[withAbsolute.length - 1] + + const blackWinrateStart = first.blackWinrateBefore + const blackScoreLeadStart = first.blackScoreLeadBefore + const blackWinrateEnd = last.blackWinrateAfter + const blackScoreLeadEnd = last.blackScoreLeadAfter + + const totalBlackWinrateChange = + typeof blackWinrateEnd === 'number' && typeof blackWinrateStart === 'number' + ? round2(blackWinrateEnd - blackWinrateStart) + : undefined + const totalBlackScoreLeadChange = + typeof blackScoreLeadEnd === 'number' && typeof blackScoreLeadStart === 'number' + ? round2(blackScoreLeadEnd - blackScoreLeadStart) + : undefined + + let maxSwing = 0 + for (const item of withAbsolute) { + if (typeof item.blackWinrateAfter === 'number' && typeof item.blackWinrateBefore === 'number') { + const swing = Math.abs(item.blackWinrateAfter - item.blackWinrateBefore) + if (swing > maxSwing) maxSwing = swing + } + } + const maxSingleMoveBlackWinrateSwing = maxSwing > 0 ? round2(maxSwing) : undefined + + const swingMoves = items + .filter((item) => (item.winrateLoss ?? 0) > 2) + .sort((a, b) => (b.winrateLoss ?? 0) - (a.winrateLoss ?? 0)) + .slice(0, 5) + .map((item) => ({ moveNumber: item.moveNumber, winrateLoss: round2(item.winrateLoss ?? 0) })) + + const startsAtRequestedStart = + options?.expectedStart === undefined || first.moveNumber === options.expectedStart + const endsAtRequestedEnd = + options?.expectedEnd === undefined || last.moveNumber === options.expectedEnd + + return { + blackWinrateStart, + blackWinrateEnd, + blackScoreLeadStart, + blackScoreLeadEnd, + totalBlackWinrateChange, + totalBlackScoreLeadChange, + maxSingleMoveBlackWinrateSwing, + swingMoves, + startsAtRequestedStart, + endsAtRequestedEnd + } +} + +function round2(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/tests/board-text-render.test.mjs b/tests/board-text-render.test.mjs new file mode 100644 index 0000000..f0d4563 --- /dev/null +++ b/tests/board-text-render.test.mjs @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { tmpdir } from 'node:os' +import { after, before, test } from 'node:test' +import ts from 'typescript' + +// --- Contract tests: verify source patterns --- + +const boardTextRenderPath = join(import.meta.dirname, '../src/main/services/go/boardTextRender.ts') +const boardTextRenderSource = readFileSync(boardTextRenderPath, 'utf-8') + +const boardStatePath = join(import.meta.dirname, '../src/main/services/go/boardState.ts') +const boardStateSource = readFileSync(boardStatePath, 'utf-8') + +test('renderBoardText uses coordToGtp from boardState for column letters', () => { + assert.match(boardTextRenderSource, /import.*coordToGtp.*from '\.\/boardState'/) + assert.match(boardTextRenderSource, /coordToGtp\(0,\s*col/) +}) + +test('renderBoardText uses gtpToCoord from boardState for snapshot parsing', () => { + assert.match(boardTextRenderSource, /import.*gtpToCoord.*from '\.\/boardState'/) + assert.match(boardTextRenderSource, /gtpToCoord\(s\.point/) +}) + +test('renderBoardText has no local GTP_LETTERS constant', () => { + assert.doesNotMatch(boardTextRenderSource, /const\s+GTP_LETTERS/) + assert.doesNotMatch(boardTextRenderSource, /GTP_LETTERS/) +}) + +test('renderBoardText skips last-move marker on pass', () => { + assert.match(boardTextRenderSource, /!lastMove\.pass/) +}) + +test('renderBoardText passes initialStones to buildBoardState', () => { + assert.match(boardTextRenderSource, /initialStones:\s*record\.initialStones/) +}) + +test('boardState coordToGtp uses GTP_LETTERS that skip I', () => { + assert.match(boardStateSource, /GTP_LETTERS\s*=\s*['"]ABCDEFGHJKLMNOPQRST/) + const match = boardStateSource.match(/GTP_LETTERS\s*=\s*['"]([^'"]+)['"]/) + assert.ok(match, 'GTP_LETTERS constant not found') + assert.ok(!match[1].includes('I'), 'GTP_LETTERS should not contain I') +}) + +// --- Compile + import real renderBoardText --- + +async function importBoardTextRenderForTest() { + const root = await mkdtemp(join(tmpdir(), 'gomentor-board-text-test-')) + const goDir = join(root, 'go') + await mkdir(goDir, { recursive: true }) + + const compilerOptions = { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022, + verbatimModuleSyntax: false + } + + const boardStateSrc = await readFile(new URL('../src/main/services/go/boardState.ts', import.meta.url), 'utf8') + const boardTextRenderSrc = (await readFile(new URL('../src/main/services/go/boardTextRender.ts', import.meta.url), 'utf8')) + .replace(/from ['"]\.\/boardState['"]/, "from './boardState.js'") + + await writeFile(join(goDir, 'boardState.js'), ts.transpileModule(boardStateSrc, { compilerOptions }).outputText, 'utf8') + await writeFile(join(goDir, 'boardTextRender.js'), ts.transpileModule(boardTextRenderSrc, { compilerOptions }).outputText, 'utf8') + + const moduleUrl = pathToFileURL(join(goDir, 'boardTextRender.js')).href + const mod = await import(`${moduleUrl}?t=${Date.now()}`) + return { + renderBoardText: mod.renderBoardText, + cleanup: () => rm(root, { recursive: true, force: true }) + } +} + +// Shared test data helpers + +const GTP_LETTERS = 'ABCDEFGHJKLMNOPQRSTUVWXYZ' + +function makeMove(moveNumber, color, row, col, pass = false) { + const gtp = pass ? 'pass' : `${GTP_LETTERS[col]}${19 - row}` + return { moveNumber, color, point: gtp, row: pass ? null : row, col: pass ? null : col, gtp, pass } +} + +function makeStone(color, row, col) { + const gtp = `${GTP_LETTERS[col]}${19 - row}` + return { color, row, col, point: gtp } +} + +// --- Functional tests: test the real compiled renderBoardText --- + +let renderBoardText +let cleanup + +before(async () => { + const result = await importBoardTextRenderForTest() + renderBoardText = result.renderBoardText + cleanup = result.cleanup + assert.ok(typeof renderBoardText === 'function', 'renderBoardText should be a function') +}) + +after(async () => { + if (cleanup) await cleanup() +}) + +// GTP column letters skip I + +test('19x19 columns skip I', () => { + const text = renderBoardText({ boardSize: 19, moves: [] }, 0) + const header = text.split('\n')[0] + const cols = header.trim().split(/\s+/) + assert.equal(cols.length, 19) + assert.equal(cols[7], 'H') + assert.equal(cols[8], 'J') + assert.doesNotMatch(header, /\bI\b/) +}) + +test('9x9 columns skip I', () => { + const text = renderBoardText({ boardSize: 9, moves: [] }, 0) + const header = text.split('\n')[0] + const cols = header.trim().split(/\s+/) + assert.equal(cols.length, 9) + assert.equal(cols[7], 'H') + assert.equal(cols[8], 'J') +}) + +// Pass moves do not get last-move marker + +test('last move is pass → no ◉/◎ marker', () => { + const moves = [ + makeMove(1, 'B', 0, 0), + makeMove(2, 'W', 0, 1), + makeMove(3, 'B', 0, 0, true) // pass + ] + const text = renderBoardText({ boardSize: 19, moves }, 3) + assert.doesNotMatch(text, /◉/) + assert.doesNotMatch(text, /◎/) + assert.match(text, /●/) + assert.match(text, /○/) +}) + +test('last move is white placement → ◎ marker', () => { + const moves = [ + makeMove(1, 'B', 0, 0), + makeMove(2, 'W', 0, 1) + ] + const text = renderBoardText({ boardSize: 19, moves }, 2) + assert.match(text, /◎/) + assert.doesNotMatch(text, /◉/) +}) + +test('last move is black placement → ◉ marker', () => { + const moves = [makeMove(1, 'B', 3, 3)] + const text = renderBoardText({ boardSize: 19, moves }, 1) + assert.match(text, /◉/) + assert.doesNotMatch(text, /◎/) +}) + +// initialStones + +test('initialStones appear on empty board', () => { + const stones = [ + makeStone('B', 3, 3), + makeStone('W', 15, 15), + makeStone('B', 3, 15), + ] + const text = renderBoardText({ boardSize: 19, moves: [], initialStones: stones }, 0) + const blackCount = (text.match(/●/g) || []).length + const whiteCount = (text.match(/○/g) || []).length + assert.equal(blackCount, 2) + assert.equal(whiteCount, 1) +}) + +test('initialStones coexist with played moves', () => { + const stones = [makeStone('B', 0, 0)] + const moves = [ + makeMove(1, 'W', 3, 3), + makeMove(2, 'B', 3, 15), + ] + const text = renderBoardText({ boardSize: 19, moves, initialStones: stones }, 2) + const blackCount = (text.match(/●|◉/g) || []).length + const whiteCount = (text.match(/○|◎/g) || []).length + assert.equal(blackCount, 2) + assert.equal(whiteCount, 1) +}) + +test('initialStones removed by capture from played moves', () => { + // Black initial stone at A19 (row=0, col=0) + // White plays B19, A18, B18 to surround and capture A19 + const stones = [makeStone('B', 0, 0)] + const moves = [ + makeMove(1, 'W', 0, 1), // B19 + makeMove(2, 'B', 18, 18), // A1 (unrelated) + makeMove(3, 'W', 1, 0), // A18 + makeMove(4, 'B', 17, 17), // B2 (unrelated) + makeMove(5, 'W', 1, 1), // B18 — completes capture of A19 + ] + const text = renderBoardText({ boardSize: 19, moves, initialStones: stones }, 5) + // A19 is row 19 (first data line) — the black stone should be gone + const row19 = text.split('\n')[1] + assert.doesNotMatch(row19, /●/, 'A19 black stone should be captured') + assert.doesNotMatch(row19, /◉/, 'A19 should not have last-move marker') + // White stones at B19, A18, B18 should exist + const whiteCount = (text.match(/○|◎/g) || []).length + assert.ok(whiteCount >= 3, `expected at least 3 white stones, got ${whiteCount}`) +}) + +// Board dimensions + +test('19x19 has 20 lines (1 header + 19 rows)', () => { + const text = renderBoardText({ boardSize: 19, moves: [] }, 0) + const lines = text.split('\n') + assert.equal(lines.length, 20) + assert.match(lines[1], /^19\b/) + assert.match(lines[19], /^ 1\b/) +}) + +test('9x9 has 10 lines (1 header + 9 rows)', () => { + const text = renderBoardText({ boardSize: 9, moves: [] }, 0) + const lines = text.split('\n') + assert.equal(lines.length, 10) + assert.match(lines[1], /^ 9\b/) + assert.match(lines[9], /^ 1\b/) +}) + +// Empty board + +test('empty board is all dots', () => { + const text = renderBoardText({ boardSize: 9, moves: [] }, 0) + const dataLines = text.split('\n').slice(1) + for (const line of dataLines) { + const cells = line.trim().split(/\s+/).slice(1) + for (const cell of cells) { + assert.equal(cell, '·') + } + } +}) + +// Boundary: uptoMoveNumber clamping + +test('uptoMoveNumber=0 shows empty board', () => { + const moves = [makeMove(1, 'B', 3, 3)] + const text = renderBoardText({ boardSize: 19, moves }, 0) + assert.doesNotMatch(text, /●/) + assert.doesNotMatch(text, /◉/) +}) + +test('uptoMoveNumber exceeds moves.length → shows all moves', () => { + const moves = [makeMove(1, 'B', 3, 3)] + const text = renderBoardText({ boardSize: 19, moves }, 999) + assert.match(text, /◉/) +}) + diff --git a/tests/intent-classifier-move-range.test.mjs b/tests/intent-classifier-move-range.test.mjs new file mode 100644 index 0000000..e80ee58 --- /dev/null +++ b/tests/intent-classifier-move-range.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +// Contract-style tests: verify code structure since TS import with aliases +// is not available in the plain Node test runner. + +const classifier = await readFile(new URL('../src/main/services/teacher/intentClassifier.ts', import.meta.url), 'utf8') + +test('move-range classification requires gameId for parser-only path', () => { + assert.match(classifier, /request\.gameId\s*&&\s*\(\s*request\.moveRange\s*\|\|\s*parseMoveRangeFromPrompt/) +}) + +test('mode=move-range always classifies regardless of gameId', () => { + const modeBlock = classifier.match(/request\.mode\s*===\s*['"]move-range['"][\s\S]*?return\s*\{[^}]*intent:\s*['"]move-range['"]/)?.[0] + assert.ok(modeBlock, 'should have a mode=move-range early return block') + assert.doesNotMatch(modeBlock, /gameId/, 'mode=move-range block should not require gameId') +}) + +test('no standalone parseMoveRangeFromPrompt gate without gameId', () => { + const lines = classifier.split('\n') + let foundBareParserCheck = false + for (const line of lines) { + if ( + line.includes('parseMoveRangeFromPrompt') && + !line.includes('request.gameId') && + !line.includes('import') && + !line.includes('//') && + line.includes('request.prompt') + ) { + const prevLines = lines.slice(Math.max(0, lines.indexOf(line) - 5), lines.indexOf(line)).join('\n') + if (!prevLines.includes('request.gameId') && line.includes('parseMoveRangeFromPrompt') && line.includes('if')) { + foundBareParserCheck = true + } + } + } + assert.ok(!foundBareParserCheck, 'parseMoveRangeFromPrompt should not be used in an if-condition without gameId gate') +}) + +test('single-move prompt should not be move-range', () => { + assert.match(classifier, /第\\s\*\\d\+\\s\*手/) +}) + +test('intentClassifier imports shared parser from moveRange', () => { + assert.match(classifier, /import\s*\{[^}]*parseMoveRangeFromPrompt[^}]*\}\s*from\s*['"]@shared\/moveRange['"]/) +}) diff --git a/tests/move-range-analysis.test.mjs b/tests/move-range-analysis.test.mjs new file mode 100644 index 0000000..f1e21bc --- /dev/null +++ b/tests/move-range-analysis.test.mjs @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { buildMoveRangeProgression } from '../src/shared/moveRangeAnalysis.ts' + +// --- Happy path: full range with absolute values --- + +test('progression computes start/end winrate and scoreLead', () => { + const items = [ + { moveNumber: 10, blackWinrateBefore: 55, blackScoreLeadBefore: 2.0, blackWinrateAfter: 52, blackScoreLeadAfter: 1.0, winrateLoss: 3 }, + { moveNumber: 11, blackWinrateBefore: 52, blackScoreLeadBefore: 1.0, blackWinrateAfter: 48, blackScoreLeadAfter: -1.5, winrateLoss: 4 }, + { moveNumber: 12, blackWinrateBefore: 48, blackScoreLeadBefore: -1.5, blackWinrateAfter: 50, blackScoreLeadAfter: 0.5, winrateLoss: 0 } + ] + const result = buildMoveRangeProgression(items) + assert.equal(result.blackWinrateStart, 55) + assert.equal(result.blackWinrateEnd, 50) + assert.equal(result.blackScoreLeadStart, 2) + assert.equal(result.blackScoreLeadEnd, 0.5) +}) + +test('progression computes total change', () => { + const items = [ + { moveNumber: 1, blackWinrateBefore: 50, blackScoreLeadBefore: 0, blackWinrateAfter: 55, blackScoreLeadAfter: 3, winrateLoss: 0 }, + { moveNumber: 2, blackWinrateBefore: 55, blackScoreLeadBefore: 3, blackWinrateAfter: 60, blackScoreLeadAfter: 5, winrateLoss: 0 } + ] + const result = buildMoveRangeProgression(items) + assert.equal(result.totalBlackWinrateChange, 10) + assert.equal(result.totalBlackScoreLeadChange, 5) +}) + +test('progression computes max single move swing', () => { + const items = [ + { moveNumber: 1, blackWinrateBefore: 50, blackScoreLeadBefore: 0, blackWinrateAfter: 55, blackScoreLeadAfter: 2, winrateLoss: 5 }, + { moveNumber: 2, blackWinrateBefore: 55, blackScoreLeadBefore: 2, blackWinrateAfter: 40, blackScoreLeadAfter: -5, winrateLoss: 15 }, + { moveNumber: 3, blackWinrateBefore: 40, blackScoreLeadBefore: -5, blackWinrateAfter: 42, blackScoreLeadAfter: -4, winrateLoss: 0 } + ] + const result = buildMoveRangeProgression(items) + assert.equal(result.maxSingleMoveBlackWinrateSwing, 15) +}) + +test('swingMoves includes only moves with winrateLoss > 2', () => { + const items = [ + { moveNumber: 10, blackWinrateBefore: 50, blackWinrateAfter: 55, winrateLoss: 5 }, + { moveNumber: 11, blackWinrateBefore: 55, blackWinrateAfter: 54, winrateLoss: 1 }, + { moveNumber: 12, blackWinrateBefore: 54, blackWinrateAfter: 48, winrateLoss: 6 }, + { moveNumber: 13, blackWinrateBefore: 48, blackWinrateAfter: 47.5, winrateLoss: 0.5 } + ] + const result = buildMoveRangeProgression(items) + assert.deepEqual(result.swingMoves, [ + { moveNumber: 12, winrateLoss: 6 }, + { moveNumber: 10, winrateLoss: 5 } + ]) +}) + +test('swingMoves is empty when all moves are good (loss <= 2)', () => { + const items = [ + { moveNumber: 1, blackWinrateBefore: 50, blackWinrateAfter: 51, winrateLoss: 1 }, + { moveNumber: 2, blackWinrateBefore: 51, blackWinrateAfter: 52, winrateLoss: 0.5 } + ] + const result = buildMoveRangeProgression(items) + assert.deepEqual(result.swingMoves, []) +}) + +test('swingMoves caps at 5 entries sorted by loss descending', () => { + const items = Array.from({ length: 8 }, (_, i) => ({ + moveNumber: i + 1, + blackWinrateBefore: 50, + blackWinrateAfter: 40, + winrateLoss: 10 - i + })) + const result = buildMoveRangeProgression(items) + assert.equal(result.swingMoves.length, 5) + assert.equal(result.swingMoves[0].winrateLoss, 10) + assert.equal(result.swingMoves[4].winrateLoss, 6) +}) + +// --- Edge cases --- + +test('empty array returns null', () => { + assert.equal(buildMoveRangeProgression([]), null) +}) + +test('items without absolute values returns null', () => { + const items = [ + { moveNumber: 1, winrateLoss: 5 }, + { moveNumber: 2, winrateLoss: 3 } + ] + assert.equal(buildMoveRangeProgression(items), null) +}) + +test('single item returns valid progression', () => { + const items = [ + { moveNumber: 5, blackWinrateBefore: 60, blackScoreLeadBefore: 3, blackWinrateAfter: 55, blackScoreLeadAfter: 1, winrateLoss: 5 } + ] + const result = buildMoveRangeProgression(items) + assert.equal(result.blackWinrateStart, 60) + assert.equal(result.blackWinrateEnd, 55) + assert.equal(result.totalBlackWinrateChange, -5) + assert.equal(result.totalBlackScoreLeadChange, -2) + assert.deepEqual(result.swingMoves, [{ moveNumber: 5, winrateLoss: 5 }]) +}) + +// --- Coverage flags --- + +test('startsAtRequestedStart=true when first item matches expected start', () => { + const items = [ + { moveNumber: 100, blackWinrateBefore: 50, blackWinrateAfter: 55, winrateLoss: 0 }, + { moveNumber: 101, blackWinrateBefore: 55, blackWinrateAfter: 60, winrateLoss: 0 } + ] + const result = buildMoveRangeProgression(items, { expectedStart: 100, expectedEnd: 110 }) + assert.equal(result.startsAtRequestedStart, true) + assert.equal(result.endsAtRequestedEnd, false) +}) + +test('endsAtRequestedEnd=true when last item matches expected end', () => { + const items = [ + { moveNumber: 100, blackWinrateBefore: 50, blackWinrateAfter: 55, winrateLoss: 0 }, + { moveNumber: 110, blackWinrateBefore: 55, blackWinrateAfter: 60, winrateLoss: 0 } + ] + const result = buildMoveRangeProgression(items, { expectedStart: 100, expectedEnd: 110 }) + assert.equal(result.startsAtRequestedStart, true) + assert.equal(result.endsAtRequestedEnd, true) +}) + +test('both flags false when data does not cover range endpoints', () => { + const items = [ + { moveNumber: 105, blackWinrateBefore: 50, blackWinrateAfter: 55, winrateLoss: 0 } + ] + const result = buildMoveRangeProgression(items, { expectedStart: 100, expectedEnd: 110 }) + assert.equal(result.startsAtRequestedStart, false) + assert.equal(result.endsAtRequestedEnd, false) +}) + +test('flags default to true when no options provided', () => { + const items = [ + { moveNumber: 5, blackWinrateBefore: 50, blackWinrateAfter: 55, winrateLoss: 0 } + ] + const result = buildMoveRangeProgression(items) + assert.equal(result.startsAtRequestedStart, true) + assert.equal(result.endsAtRequestedEnd, true) +}) + +// --- Unordered input is sorted by moveNumber --- + +test('items sorted by moveNumber regardless of input order', () => { + const items = [ + { moveNumber: 12, blackWinrateBefore: 48, blackWinrateAfter: 50, winrateLoss: 0 }, + { moveNumber: 10, blackWinrateBefore: 55, blackWinrateAfter: 52, winrateLoss: 3 }, + { moveNumber: 11, blackWinrateBefore: 52, blackWinrateAfter: 48, winrateLoss: 4 } + ] + const result = buildMoveRangeProgression(items) + assert.equal(result.blackWinrateStart, 55) + assert.equal(result.blackWinrateEnd, 50) +}) diff --git a/tests/move-range-parser.test.mjs b/tests/move-range-parser.test.mjs new file mode 100644 index 0000000..6945895 --- /dev/null +++ b/tests/move-range-parser.test.mjs @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { parseMoveRangeFromPrompt } from '../src/main/lib/moveRange.ts' + +// --- Contextual patterns (always accepted) --- + +test('Chinese: 分析第100手到第200手', () => { + const r = parseMoveRangeFromPrompt('分析第100手到第200手') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('Chinese: 100手至200手', () => { + const r = parseMoveRangeFromPrompt('100手至200手') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('Chinese: 第200手到第100手 normalizes', () => { + const r = parseMoveRangeFromPrompt('分析第200手到第100手') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('Japanese: 100手から200手', () => { + const r = parseMoveRangeFromPrompt('100手から200手') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('Japanese: 第100手から第200手', () => { + const r = parseMoveRangeFromPrompt('第100手から第200手') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('Korean: 100수부터200수', () => { + const r = parseMoveRangeFromPrompt('100수부터200수') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('English: moves 100-200', () => { + const r = parseMoveRangeFromPrompt('moves 100-200') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('English: from move 100 to 200', () => { + const r = parseMoveRangeFromPrompt('from move 100 to 200') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('English: from 100 to 200', () => { + const r = parseMoveRangeFromPrompt('from 100 to 200') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('English: move 100 to move 200', () => { + const r = parseMoveRangeFromPrompt('move 100 to move 200') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('English: moves 100 to 200', () => { + const r = parseMoveRangeFromPrompt('moves 100 to 200') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('English: moves 100 through 200', () => { + const r = parseMoveRangeFromPrompt('moves 100 through 200') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +// --- Bare numeric: only accepted for short command-like prompts --- + +test('Bare 100-200 as short command', () => { + const r = parseMoveRangeFromPrompt('100-200') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('Bare 分析 50-80 with prefix', () => { + const r = parseMoveRangeFromPrompt('分析 50-80') + assert.deepEqual(r, { start: 50, end: 80 }) +}) + +test('Bare analyze 100-200', () => { + const r = parseMoveRangeFromPrompt('analyze 100-200') + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +// --- False positives: must return null --- + +test('Single move: 分析第100手 → null', () => { + assert.equal(parseMoveRangeFromPrompt('分析第100手'), null) +}) + +test('Date: 2026-05-02 → null', () => { + assert.equal(parseMoveRangeFromPrompt('2026-05-02'), null) +}) + +test('Date in sentence: 2026-05-02 这盘棋怎么样 → null', () => { + assert.equal(parseMoveRangeFromPrompt('2026-05-02 这盘棋怎么样'), null) +}) + +test('Score: 3-5目 → null', () => { + assert.equal(parseMoveRangeFromPrompt('3-5目'), null) +}) + +test('Winrate: 胜率从 45-55 → null', () => { + assert.equal(parseMoveRangeFromPrompt('胜率从 45-55'), null) +}) + +test('Prose: 这盘棋3-5目差距不大 → null', () => { + assert.equal(parseMoveRangeFromPrompt('这盘棋3-5目差距不大'), null) +}) + +test('Bare English "100 to 200" → null', () => { + assert.equal(parseMoveRangeFromPrompt('100 to 200'), null) +}) + +test('Prose: winrate from 45 to 55 → null', () => { + assert.equal(parseMoveRangeFromPrompt('winrate from 45 to 55'), null) +}) + +test('Prose: score changed from 3 to 5 → null', () => { + assert.equal(parseMoveRangeFromPrompt('score changed from 3 to 5'), null) +}) + +test('start < 1: 分析第0手到第10手 → null', () => { + assert.equal(parseMoveRangeFromPrompt('分析第0手到第10手'), null) +}) + +test('totalMoves clamp: end > totalMoves → null', () => { + assert.equal(parseMoveRangeFromPrompt('分析第100手到第200手', 150), null) +}) + +test('totalMoves ok: end <= totalMoves', () => { + const r = parseMoveRangeFromPrompt('分析第100手到第200手', 250) + assert.deepEqual(r, { start: 100, end: 200 }) +}) + +test('Empty string → null', () => { + assert.equal(parseMoveRangeFromPrompt(''), null) +})