Skip to content
Closed
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
90 changes: 90 additions & 0 deletions src/main/lib/moveRange.ts
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions src/main/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -490,6 +498,7 @@ export interface MoveRangeReviewSummary {
keyMoves: MoveRangeKeyMoveSummary[]
omittedMoves: number
analysisMethod: string
progression?: MoveRangeProgression
}

export interface TeacherRunRequest {
Expand Down
58 changes: 58 additions & 0 deletions src/main/services/go/boardTextRender.ts
Original file line number Diff line number Diff line change
@@ -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<string, StoneColor>()
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')
}
50 changes: 50 additions & 0 deletions src/main/services/katago.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,56 @@ function buildMoveAnalysis(
}
}

export async function analyzeMoveRange(
gameId: string,
startMove: number,
endMove: number,
maxVisits = 200
): Promise<KataGoMoveAnalysis[]> {
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,
Expand Down
78 changes: 68 additions & 10 deletions src/main/services/teacher/moveRangeReview.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
}

Expand All @@ -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')
}
Expand Down
Loading
Loading