diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5551d07..c9d0c63 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a reproducible KataSensei problem. +description: Report a reproducible GoMentor problem. title: "[Bug]: " labels: ["bug"] body: @@ -23,8 +23,8 @@ body: - type: input id: version attributes: - label: KataSensei version - placeholder: "0.1.0" + label: GoMentor version + placeholder: "0.2.0-beta.1" - type: dropdown id: os attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE/p0-beta.md b/.github/PULL_REQUEST_TEMPLATE/p0-beta.md new file mode 100644 index 0000000..22bead8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/p0-beta.md @@ -0,0 +1,60 @@ +# GoMentor P0 Beta PR + +## Summary + +- + +## Scope + +- [ ] Diagnostics / onboarding +- [ ] KataGo runtime / asset readiness +- [ ] SGF import and student binding +- [ ] Fox nickname import and profile reuse +- [ ] Teacher runtime +- [ ] Local knowledge cards +- [ ] Board UI / teacher UI +- [ ] Packaging / release readiness + +## Verification + +- [ ] `pnpm install` +- [ ] `pnpm typecheck` +- [ ] `pnpm test` +- [ ] `pnpm build` +- [ ] `pnpm check` +- [ ] `node scripts/check_katago_assets.mjs --mode=dev` +- [ ] `node scripts/p0_beta_acceptance.mjs` +- [ ] `node scripts/package_artifact_smoke.mjs --mode=dev` +- [ ] `node scripts/p0_release_candidate_check.mjs --mode=dev` +- [ ] `node scripts/verify_release_artifacts.mjs --mode=dev` + +## Manual QA + +- [ ] macOS app launches +- [ ] macOS app is signed and notarized before public beta +- [ ] Windows 11 x64 app launches +- [ ] Windows installer is signed before public beta, or explicitly marked internal/unsigned beta +- [ ] SGF import works +- [ ] Fox nickname sync works +- [ ] Current move analysis works +- [ ] Full game analysis works +- [ ] Recent 10 games analysis works +- [ ] Student profile updates after analysis +- [ ] Teacher card output is readable +- [ ] Candidate tooltip works +- [ ] Key move navigation works +- [ ] Winrate timeline click/drag works +- [ ] Visual QA evidence captured + +## Release asset status + +- [ ] macOS KataGo binary prepared +- [ ] Windows x64 KataGo binary prepared +- [ ] Default b18 model prepared +- [ ] Asset checksums recorded +- [ ] Release package smoke test passed +- [ ] No Windows ARM64 artifact is generated or uploaded for P0 beta + +## Risk / Notes + +- diff --git a/.github/workflows/p0-release-candidate.yml b/.github/workflows/p0-release-candidate.yml new file mode 100644 index 0000000..db8258c --- /dev/null +++ b/.github/workflows/p0-release-candidate.yml @@ -0,0 +1,92 @@ +name: P0 Release Candidate + +on: + pull_request: + branches: + - main + - develop + push: + branches: + - feature/p0-productization + workflow_dispatch: + inputs: + mode: + description: "Readiness mode" + required: true + default: "dev" + type: choice + options: + - dev + - release + +concurrency: + group: p0-rc-${{ github.ref }} + cancel-in-progress: true + +jobs: + readiness: + name: P0 RC readiness (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - macos-latest + - windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.30.3 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build + + - name: Project check + run: pnpm check + + - name: KataGo assets check + run: node scripts/check_katago_assets.mjs --mode=${{ inputs.mode || 'dev' }} + + - name: P0 beta acceptance + run: node scripts/p0_beta_acceptance.mjs + + - name: Package artifact smoke + run: node scripts/package_artifact_smoke.mjs --mode=dev + + - name: P0 release candidate check + run: node scripts/p0_release_candidate_check.mjs --mode=${{ inputs.mode || 'dev' }} + + - name: Verify release artifacts + run: node scripts/verify_release_artifacts.mjs --mode=dev + + - name: Collect release evidence + if: always() + run: node scripts/collect_release_evidence.mjs --mode=dev + + - name: Upload release evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-evidence-${{ matrix.os }} + path: release-evidence/** + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cb9ea8..c843644 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,33 @@ concurrency: cancel-in-progress: false jobs: + release-preflight: + name: Release preflight + runs-on: ubuntu-latest + outputs: + can-package: ${{ steps.katago-assets.outputs.can-package }} + + steps: + - name: Check KataGo asset sources + id: katago-assets + shell: bash + env: + GOMENTOR_KATAGO_ASSET_DIR: ${{ secrets.GOMENTOR_KATAGO_ASSET_DIR }} + GOMENTOR_KATAGO_BINARY: ${{ secrets.GOMENTOR_KATAGO_BINARY }} + GOMENTOR_KATAGO_MODEL: ${{ secrets.GOMENTOR_KATAGO_MODEL }} + run: | + if [[ -n "${GOMENTOR_KATAGO_ASSET_DIR}" || ( -n "${GOMENTOR_KATAGO_BINARY}" && -n "${GOMENTOR_KATAGO_MODEL}" ) ]]; then + echo "can-package=true" >> "${GITHUB_OUTPUT}" + echo "KataGo release asset source is configured." + else + echo "can-package=false" >> "${GITHUB_OUTPUT}" + echo "::notice title=Release packaging skipped::KataGo release assets are not configured. Set GOMENTOR_KATAGO_ASSET_DIR or both GOMENTOR_KATAGO_BINARY and GOMENTOR_KATAGO_MODEL to enable automated packaging." + fi + package: name: Package ${{ matrix.name }} + needs: release-preflight + if: needs.release-preflight.outputs.can-package == 'true' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -27,9 +52,6 @@ jobs: - name: Windows os: windows-latest command: pnpm dist:win - - name: Linux - os: ubuntu-latest - command: pnpm dist:linux steps: - name: Checkout @@ -58,15 +80,36 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: Prepare KataGo assets + shell: bash + env: + GOMENTOR_KATAGO_ASSET_DIR: ${{ secrets.GOMENTOR_KATAGO_ASSET_DIR }} + GOMENTOR_KATAGO_BINARY: ${{ secrets.GOMENTOR_KATAGO_BINARY }} + GOMENTOR_KATAGO_MODEL: ${{ secrets.GOMENTOR_KATAGO_MODEL }} + run: | + node scripts/prepare_katago_assets.mjs + node scripts/check_katago_assets.mjs --mode=release + - name: Package desktop app env: - CSC_IDENTITY_AUTO_DISCOVERY: "false" + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} + WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_KEYCHAIN: ${{ secrets.APPLE_KEYCHAIN }} + APPLE_KEYCHAIN_PROFILE: ${{ secrets.APPLE_KEYCHAIN_PROFILE }} run: ${{ matrix.command }} - name: Upload packaged artifacts uses: actions/upload-artifact@v4 with: - name: katasensei-${{ matrix.name }} + name: GoMentor-${{ matrix.name }} path: | release/**/*.AppImage release/**/*.deb @@ -79,9 +122,11 @@ jobs: github-release: name: Publish GitHub Release - needs: package + needs: + - release-preflight + - package runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') + if: needs.release-preflight.outputs.can-package == 'true' && startsWith(github.ref, 'refs/tags/') steps: - name: Download artifacts diff --git a/.gitignore b/.gitignore index 70666e7..cb592fb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,12 @@ dist release .DS_Store *.log -.katasensei +.gomentor __pycache__ *.pyc coverage +release-evidence +.playwright-cli *.tsbuildinfo diff --git a/CHANGELOG.md b/CHANGELOG.md index ec9a8c3..d2fe7fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Changelog -All notable changes to KataSensei will be documented here. +All notable changes to GoMentor will be documented here. This project follows semantic versioning once public releases begin. -## 0.1.0 - Unreleased +## 0.2.0-beta.1 - P0 Beta Candidate ### Added @@ -22,6 +22,8 @@ This project follows semantic versioning once public releases begin. - Markdown and JSON report output. - Cross-platform CI for macOS, Windows, and Linux. - GitHub Release workflow for macOS, Windows, and Linux artifacts. +- P0 release readiness checks for automation, assets, installers, signing, Windows smoke, and visual QA. +- Local release evidence collection under `release-evidence/`. ### Fixed @@ -29,3 +31,10 @@ This project follows semantic versioning once public releases begin. - Fox-style SGF komi values such as `KM[375]`. - SGF parser incorrectly reading comments and variations as mainline moves. - Board and winrate graph layout overlap in the center workspace. + +### Known Issues + +- Windows ARM64 is not supported in the P0 beta because the bundled KataGo manifest only supports Windows x64. +- macOS public distribution requires Developer ID signing and notarization before tagging. +- Windows public distribution should use an EV/OV certificate or Microsoft Trusted Signing; unsigned installers are internal beta only. +- Windows 11 x64 real-machine smoke and visual QA evidence are required before creating `v0.2.0-beta.1`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ede5c83..edf8599 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Contributing to KataSensei +# Contributing to GoMentor -Thanks for helping build KataSensei. This project sits at the intersection of Go education, local desktop engineering, KataGo analysis, and LLM agent design, so changes should be careful and grounded. +Thanks for helping build GoMentor. This project sits at the intersection of Go education, local desktop engineering, KataGo analysis, and LLM agent design, so changes should be careful and grounded. ## Development Setup @@ -59,7 +59,7 @@ When adding a tool: - TypeScript for Electron and React code. - Keep IPC boundaries explicit and typed in `src/main/lib/types.ts`. - Prefer structured SGF/KataGo parsing over string heuristics. -- Keep local file access inside KataSensei-managed directories unless the user explicitly chooses files. +- Keep local file access inside GoMentor-managed directories unless the user explicitly chooses files. ## Release Process diff --git a/README.md b/README.md index 6545b11..f48bda8 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,130 @@

- KataSensei logo + GoMentor logo

-# KataSensei +

GoMentor

-> 像 Cursor / Claude Code 一样会调用工具的 AI 围棋老师。KataGo 负责事实判断,多模态 LLM 负责教学表达,学生画像负责长期进步。 +

+ 像 AI 编辑器一样工作的围棋老师。
+ KataGo 负责事实判断,多模态 LLM 负责讲清楚,学生画像负责长期进步。 +

-[![CI](https://github.com/wimi321/katasensei/actions/workflows/ci.yml/badge.svg)](https://github.com/wimi321/katasensei/actions/workflows/ci.yml) -[![Release](https://github.com/wimi321/katasensei/actions/workflows/release.yml/badge.svg)](https://github.com/wimi321/katasensei/actions/workflows/release.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +

+ Release + Downloads + Stars + CI + License + QQ Group +

-KataSensei 是一个本地优先的跨平台桌面应用,目标不是再做一个普通复盘工具,而是做一个能长期陪学生学围棋的智能体老师: +

+ 中文 | + English | + 日本語 | + 한국어 | + ไทย | + Tiếng Việt +

-- 左侧同步和管理野狐 / SGF 棋谱。 -- 中间是 KTrain / Lizzie 风格棋盘与整盘胜率图。 -- 右侧是会规划、会调用 KataGo、会查知识库、会写报告的 AI 围棋老师。 -- 老师可以直接执行“分析当前手”“分析整盘围棋”“分析近 10 局围棋”“给学生做训练计划”等自然语言任务。 +

+ 加入 GoMentor 交流群:QQ 1030632742
+ 欢迎交流使用体验、提交建议、反馈 bug,一起把 AI 围棋老师打磨好。 +

-KataGo 的结构化分析永远是事实裁判;LLM 只负责把事实讲成学生听得懂、能执行的训练建议。 +--- -## English +GoMentor 是一个本地优先、跨平台的桌面围棋学习工作台。它不是把聊天框放在棋盘旁边,而是把 KataGo、棋盘截图、本地知识库、学生长期画像和多模态 LLM 组织成一个会执行任务的 AI 围棋老师。 -English documentation is available in [README_EN.md](./README_EN.md). +你可以直接说: -## 当前状态 +- “分析当前手为什么亏。” +- “复盘整盘棋,找出胜负转折点。” +- “分析这个棋手最近 10 局,找出最常见的问题。” +- “根据最近的弱点做一周训练计划。” -KataSensei 处于早期公开版本阶段。核心工作台、野狐同步、SGF 主线解析、KataGo 分析、快速胜率图、右侧老师智能体、多模态 LLM 配置、本地知识库和学生画像已经打通。 +KataGo 是事实裁判,LLM 是讲棋老师。GoMentor 的目标是让学生不仅知道哪一步不好,还能理解为什么不好,以及下一周该怎么练。 -仍在演进中的部分: +## 下载 -- 正式发布包的签名、公证和自动更新。 -- 更完整的 KataGo 权重下载器和校验器。 -- 更丰富的报告模板和训练题库。 -- 多语言 UI。 +当前公开测试版: -## 核心能力 +[GoMentor v0.2.0-beta.1](https://github.com/wimi321/GoMentor/releases/tag/v0.2.0-beta.1) -### 三栏围棋学习工作台 +| 平台 | 下载 | +| --- | --- | +| macOS Apple Silicon | [GoMentor-0.2.0-beta.1-mac-arm64.dmg](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-arm64.dmg) | +| macOS Intel | [GoMentor-0.2.0-beta.1-mac-x64.dmg](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-x64.dmg) | +| Windows x64 免安装 ZIP | [GoMentor-0.2.0-beta.1-win-x64-portable.zip](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64-portable.zip) | +| Windows x64 安装版 | [GoMentor-0.2.0-beta.1-win-x64.exe](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64.exe) | -- 左侧:输入野狐昵称 / UID,同步公开棋谱;支持分页浏览;支持上传本地 SGF。 -- 中间:大棋盘、黑白双方、当前手、推荐点、整盘胜率图、关键问题手误差柱。 -- 右侧:AI 围棋老师聊天区,展示工具调用日志、讲解和报告。 +Beta 说明: -### 老师是智能体,不是固定按钮 +- macOS 包目前未完成 Developer ID 签名和公证,首次打开可能出现 Gatekeeper 提示。 +- Windows 包目前未签名,可能出现 SmartScreen 提示。 +- Windows ARM64 暂不支持。 +- 大型 KataGo 二进制和模型不会作为普通 Git 文件提交。 -右侧老师拥有工具目录,可以根据任务自己选择链路: +## 你会得到什么 -- `library.findGames`:按学生、来源、日期、最近 N 盘筛选棋谱。 -- `sgf.readGameRecord`:读取 SGF 主线、棋局元信息和当前手。 -- `katago.analyzePosition`:分析当前局面。 -- `katago.analyzeGameBatch`:批量分析一盘或多盘棋。 -- `board.captureTeachingImage`:生成带坐标、最后一手和推荐点的棋盘截图。 -- `knowledge.searchLocal`:检索随应用打包的围棋知识库。 -- `studentProfile.read/write`:读写长期学生画像。 -- `report.saveAnalysis`:保存当前手、整盘、多盘和训练计划报告。 -- `web.searchGoKnowledge`:在用户需要外部资料时做泛化搜索,不发送隐私棋谱。 +### 专业围棋工作台 -### 快捷复盘 +- 左侧:棋手、野狐公开棋谱、SGF 导入和棋谱列表。 +- 中间:KTrain / Lizzie 风格棋盘、坐标、落子、推荐点、实战点、变化图预览和胜率走势。 +- 右侧:类似 AI 编辑器的老师对话区,支持自然语言任务、工具调用日志和流式讲解。 -顶部快捷按钮: +### 类 Lizzie 的分析体验 -- `分析当前手`:截图棋盘,KataGo 分析当前手,结合知识库和多模态 LLM 讲解。 -- `分析整盘围棋`:分析当前整盘棋,提取关键问题手和胜负转折点。 -- `分析近10局围棋`:筛选学生最近 10 盘,批量提取常见问题,更新学生画像。 +- 加载棋谱后默认自动开始 KataGo 分析。 +- 在胜率图或关键手上切换手数后,默认继续分析当前局面。 +- 只有用户主动点击暂停,分析才保持停止。 +- 推荐点显示选点序号、胜率、目差、搜索数。 +- 实战下一手会和 KataGo 推荐点一起对照,问题手按胜率/目差损失判断。 +- 鼠标悬停推荐点时展示后续变化,帮助学生理解“AI 为什么这样下”。 -### LLM 配置 +### AI 老师是智能体 -设置入口在右侧老师栏齿轮中: +老师不是固定模板回复。它可以根据任务调用工具: -- OpenAI-compatible Base URL。 -- API Key。 -- 多模态模型名称。 +- `library.findGames`:按棋手、来源、日期、最近 N 局筛选棋谱。 +- `sgf.readGameRecord`:读取 SGF 主线、棋局信息和当前手。 +- `katago.analyzePosition`:分析当前局面。 +- `katago.analyzeGameBatch`:批量分析一盘或多盘棋。 +- `board.captureTeachingImage`:生成带坐标、最后一手和推荐点的棋盘截图。 +- `knowledge.searchLocal`:检索本地围棋知识卡。 +- `studentProfile.read/write`:读写长期学生画像。 +- `report.saveAnalysis`:保存当前手、整盘、多盘和训练计划报告。 -KataSensei 只面向支持图片输入的多模态模型。当前手分析会把棋盘截图、KataGo JSON 和知识库片段一起发给配置的 LLM。 +### 多模态讲棋 -### KataGo 运行方式 +当前手分析会把这些信息组合给用户配置的多模态 LLM: -应用优先寻找随包携带的 KataGo 运行时: +- 当前棋盘截图。 +- KataGo JSON 分析数据。 +- 当前手、候选点、实战点、胜率/目差变化。 +- 本地知识库中检索到的 2 到 4 张教学卡。 +- 棋手画像和最近常见问题。 -```text -data/katago/ - bin/-/katago - models/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz - models/kata1-zhizi-b28c512nbt-muonfd2.bin.gz -``` +LLM 负责把这些事实讲成人能听懂、能执行的复盘建议。 -开发模式下也会回退使用本机 `katago` 和 `~/.katago/models/latest-kata1.bin.gz`。大型模型和平台二进制不会提交到 Git,请按 [data/katago/README.md](./data/katago/README.md) 放置。 +## 项目状态 -内置权重预设: +GoMentor 目前处于早期公开 Beta: -- `推荐通用 b18`:默认推荐,速度和强度平衡,适合日常教学和快速胜率图。 -- `强力精读 b28`:更适合关键局面精读,资源占用更高。 +- 已打通三栏桌面工作台。 +- 已支持野狐公开棋谱同步和本地 SGF 导入。 +- 已接入 KataGo 当前手、整盘和多盘分析链路。 +- 已支持 OpenAI-compatible 多模态 LLM 配置。 +- 已加入本地知识库、学生画像、诊断页和 release readiness 检查。 +- 正在继续打磨 UI、签名/公证、自动更新和多语言 UI。 ## 架构 ```mermaid flowchart LR - UI["React Workbench\n棋谱库 / 棋盘 / 老师聊天"] --> IPC["Electron Preload IPC"] + UI["React Workbench\n棋谱 / 棋盘 / AI 老师"] --> IPC["Electron Preload IPC"] IPC --> Main["Electron Main"] - Main --> Store["Local Store\n~/.katasensei"] + Main --> Store["Local Store\n~/.gomentor"] Main --> SGF["SGF Parser"] Main --> Fox["Fox Public Game Sync"] Main --> KataGo["KataGo Analysis Engine"] @@ -117,44 +143,41 @@ flowchart LR ```text src/main Electron 主进程、IPC、KataGo、野狐同步、老师智能体 src/preload Renderer 可用的安全桥接 API -src/renderer React 三栏工作台 +src/renderer React 三栏桌面工作台 data/knowledge 本地围棋知识库 -data/katago 可选内置 KataGo 二进制和权重布局说明 -scripts 批量复盘、KataGo 准备、开发辅助脚本 -docs 架构和老师智能体设计 +data/katago KataGo 二进制和权重布局说明 +scripts 资产检查、批量复盘、视觉 QA、release 辅助脚本 +docs 架构、发布、签名、公证、QA 文档 ``` -## 快速开始 +## 本地开发 -### 要求 +要求: - Node.js 22+ - pnpm 10+ -- Python 3.10+,用于批量 SGF/KataGo 复盘脚本 +- Python 3.10+ - KataGo 二进制和一个 KataGo 模型 - 可选:OpenAI-compatible 多模态 LLM API -### 安装依赖 +启动: ```bash pnpm install python3 -m pip install -r scripts/requirements.txt -``` - -### 启动开发版 - -```bash pnpm dev ``` -### 检查 +检查: ```bash pnpm typecheck +pnpm test pnpm build +pnpm check ``` -## 打包 +打包: ```bash pnpm dist:mac @@ -162,60 +185,49 @@ pnpm dist:win pnpm dist:linux ``` -输出目录: +## KataGo 资源 -```text -release// -``` +GoMentor 优先寻找随安装包携带的 KataGo 运行时: -发布标签会触发 GitHub Actions,分别在 macOS、Windows、Linux runner 上构建安装包,并把产物上传到 GitHub Release: - -```bash -git tag v0.1.0 -git push origin v0.1.0 +```text +data/katago/ + bin/-/katago + models/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz + models/kata1-zhizi-b28c512nbt-muonfd2.bin.gz ``` -注意:正式面向普通用户分发前,还需要配置 macOS 签名/公证、Windows 代码签名和自动更新策略。 +大型 KataGo binary/model 不作为普通 Git 文件提交。请阅读 [data/katago/README.md](./data/katago/README.md) 和 [docs/KATAGO_ASSETS.md](./docs/KATAGO_ASSETS.md)。 ## 隐私与安全 -- 棋谱库、学生画像和报告默认保存在 `~/.katasensei`。 +- 棋谱、学生画像、报告和设置默认保存在 `~/.gomentor`。 - LLM API Key 在支持的平台上使用 Electron `safeStorage` 加密保存。 -- 前端永远拿不到已保存的完整 API Key。 -- 当前手讲解会发送棋盘截图、KataGo JSON、知识库摘录到用户配置的 LLM 服务。 -- 批量分析默认只做本地 KataGo,最后再把聚合结果交给 LLM 总结。 -- Web 搜索只允许泛化围棋概念,不发送学生姓名、棋谱原文、截图、API Key 或本机路径。 +- 前端不会拿到已保存的完整 API Key。 +- 当前手讲解会发送棋盘截图、KataGo JSON 和知识库摘录到用户配置的 LLM 服务。 +- Web 搜索只用于泛化围棋概念,不发送学生姓名、棋谱原文、截图、API Key 或本机路径。 -## 开发规范 +## 社区 -提交前请至少运行: +欢迎加入 QQ 群交流、提建议、一起完善: -```bash -pnpm typecheck -pnpm build +```text +1030632742 ``` -推荐阅读: - -- [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) -- [docs/TEACHER_AGENT.md](./docs/TEACHER_AGENT.md) -- [CONTRIBUTING.md](./CONTRIBUTING.md) -- [SECURITY.md](./SECURITY.md) +你也可以通过 [Issues](https://github.com/wimi321/GoMentor/issues) 提交 bug、产品建议、UI 建议、模型配置经验和复盘样例。 ## 路线图 -- [x] 三栏工作台:棋谱库、棋盘、老师聊天。 -- [x] 野狐公开棋谱同步和 SGF 上传。 -- [x] KataGo 当前手分析。 -- [x] 加载棋谱后快速生成胜率图。 -- [x] 当前手、整盘、最近 10 局快捷智能体任务。 -- [x] 本地知识库检索。 -- [x] 学生画像读写。 -- [x] macOS / Windows / Linux CI 和 Release workflow。 -- [ ] 官方安装包签名与公证。 -- [ ] 内置 KataGo 下载器、模型校验和版本管理。 -- [ ] 更完整的训练计划和题库系统。 -- [ ] 插件化老师工具注册表。 +- [x] 三栏桌面工作台。 +- [x] 野狐公开棋谱同步和 SGF 导入。 +- [x] KataGo 当前手、整盘、多盘分析。 +- [x] 推荐点、实战点、变化图预览和胜率走势。 +- [x] 多模态 AI 老师、流式回复、本地知识库和学生画像。 +- [x] macOS / Windows Beta 安装包。 +- [ ] macOS Developer ID 签名和公证。 +- [ ] Windows 代码签名。 +- [ ] 自动更新。 +- [ ] 更完整的训练计划、题库系统和多语言 UI。 ## 致谢 @@ -223,7 +235,7 @@ pnpm build - [katagotraining.org](https://katagotraining.org/) - [Electron](https://www.electronjs.org/) - [React](https://react.dev/) -- 本地项目 `YiGo`、`LizzieYZY`、`KTrain` 风格参考 +- Lizzie / LizzieYZY / KTrain 等专业围棋分析软件带来的交互启发 ## License diff --git a/README_EN.md b/README_EN.md index 7a77c5f..e14c9a0 100644 --- a/README_EN.md +++ b/README_EN.md @@ -1,43 +1,98 @@

- KataSensei logo + GoMentor logo

-# KataSensei +

GoMentor

-> An agentic Go teacher for desktop. KataGo judges the position, a multimodal LLM explains the lesson, and a long-term student profile keeps the coaching consistent. +

+ An AI-editor-style Go teacher for desktop.
+ KataGo provides the facts, a multimodal LLM explains the lesson, and student profiles keep coaching consistent over time. +

+ +

+ Release + Downloads + Stars + CI + License + QQ Group +

+ +

+ 中文 | + English | + 日本語 | + 한국어 | + ไทย | + Tiếng Việt +

+ +

+ Join the GoMentor community: QQ 1030632742
+ Share feedback, report bugs, and help improve the AI Go teacher together. +

+ +--- + +GoMentor is a local-first, cross-platform desktop workbench for Go students and teachers. It is not just a chat panel beside a board: it turns KataGo, board screenshots, local knowledge cards, long-term student memory, and a multimodal LLM into an agentic Go teacher. + +Ask it to: + +- explain the current move, +- review the full game, +- diagnose a player's latest 10 games, +- create a one-week training plan from recurring weaknesses. + +KataGo is the source of truth. The LLM is the teacher that turns those facts into clear, actionable coaching. + +## Downloads -[![CI](https://github.com/wimi321/katasensei/actions/workflows/ci.yml/badge.svg)](https://github.com/wimi321/katasensei/actions/workflows/ci.yml) -[![Release](https://github.com/wimi321/katasensei/actions/workflows/release.yml/badge.svg)](https://github.com/wimi321/katasensei/actions/workflows/release.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +Current public beta: -KataSensei is a local-first, cross-platform desktop workbench for Go students and teachers. +[GoMentor v0.2.0-beta.1](https://github.com/wimi321/GoMentor/releases/tag/v0.2.0-beta.1) -- Left rail: sync public Fox games, upload SGF, and browse the game library. -- Center: KTrain/Lizzie-inspired board, player info, current move, candidate points, and full-game winrate graph. -- Right rail: an AI Go teacher that can plan tasks, call tools, read local knowledge, run KataGo, and save reports. +| Platform | Download | +| --- | --- | +| macOS Apple Silicon | [GoMentor-0.2.0-beta.1-mac-arm64.dmg](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-arm64.dmg) | +| macOS Intel | [GoMentor-0.2.0-beta.1-mac-x64.dmg](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-x64.dmg) | +| Windows x64 portable ZIP | [GoMentor-0.2.0-beta.1-win-x64-portable.zip](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64-portable.zip) | +| Windows x64 installer | [GoMentor-0.2.0-beta.1-win-x64.exe](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64.exe) | -The product goal is not "chat beside a board". The teacher is an agent: users can ask for current-move analysis, full-game review, recent-10-game diagnosis, training plans, or open-ended learning tasks. +Beta caveats: -## Status +- macOS packages are not yet Developer ID signed or notarized. +- Windows packages are unsigned and may trigger SmartScreen. +- Windows ARM64 is not supported in this beta. +- Large KataGo binaries and models are not committed as normal Git files. -KataSensei is an early public project. The core workbench, SGF parsing, Fox sync, KataGo analysis, quick winrate graph, teacher agent, multimodal LLM settings, local knowledge base, and student profile storage are implemented. +## Highlights -Still in progress: +### Professional Go workbench -- Signed and notarized public installers. -- A first-class KataGo model downloader and verifier. -- Richer report templates and training problem sets. -- Full UI localization. +- Left rail: players, Fox public games, SGF import, and game library. +- Center: KTrain/Lizzie-inspired board, coordinates, stones, candidates, played-move comparison, PV preview, and winrate timeline. +- Right rail: AI-editor-style teacher composer, streamed replies, tool logs, and structured review cards. -## Core Features +### Lizzie-inspired live analysis -- **Agentic teacher runtime**: a tool-using teacher inspired by Claude Code/Cursor workflows. -- **KataGo-first review**: structured KataGo data is the source of truth. -- **Multimodal current-move teaching**: sends board screenshot + KataGo facts + selected knowledge cards to an OpenAI-compatible multimodal model. -- **Full-game and recent-game reviews**: extracts mistakes, updates student profile, and writes Markdown/JSON reports. -- **Local knowledge base**: packaged Go concepts for stable teaching language. -- **Local-first storage**: games, reports, settings, and student profiles live under `~/.katasensei`. -- **Cross-platform desktop**: Electron build targets macOS, Windows, and Linux. +- Loading a game starts KataGo analysis automatically. +- Selecting a move on the winrate timeline continues analysis for that position. +- Analysis remains stopped only after the user explicitly clicks pause. +- Candidate points show rank, winrate, score lead, and visits. +- Played moves are compared against KataGo candidates, and problem moves are judged by winrate/score loss. + +### Agentic teacher runtime + +The teacher can call tools instead of following fixed templates: + +- `library.findGames` +- `sgf.readGameRecord` +- `katago.analyzePosition` +- `katago.analyzeGameBatch` +- `board.captureTeachingImage` +- `knowledge.searchLocal` +- `studentProfile.read/write` +- `report.saveAnalysis` ## Architecture @@ -47,11 +102,10 @@ flowchart LR IPC --> Main["Electron Main"] Main --> Store["Local Store"] Main --> SGF["SGF Parser"] - Main --> Fox["Fox Sync"] + Main --> Fox["Fox Public Game Sync"] Main --> KataGo["KataGo Analysis"] Main --> KB["Local Knowledge"] Main --> LLM["Multimodal LLM"] - Main --> Reports["Reports"] KataGo --> Agent["TeacherAgentRuntime"] KB --> Agent Store --> Agent @@ -59,7 +113,9 @@ flowchart LR Agent --> UI ``` -## Requirements +## Development + +Requirements: - Node.js 22+ - pnpm 10+ @@ -67,22 +123,22 @@ flowchart LR - KataGo binary and model - Optional OpenAI-compatible multimodal LLM API -## Development - ```bash pnpm install python3 -m pip install -r scripts/requirements.txt pnpm dev ``` -Verify locally: +Checks: ```bash pnpm typecheck +pnpm test pnpm build +pnpm check ``` -## Packaging +Packaging: ```bash pnpm dist:mac @@ -90,37 +146,22 @@ pnpm dist:win pnpm dist:linux ``` -Release artifacts are written to: - -```text -release// -``` +## Privacy -GitHub release builds run on native macOS, Windows, and Linux runners when a `v*.*.*` tag is pushed. +- Games, reports, settings, and student profiles stay under `~/.gomentor` by default. +- Saved LLM API keys are encrypted with Electron `safeStorage` when available. +- Current-move teaching may send a board screenshot, KataGo JSON, and selected knowledge cards to the configured LLM endpoint. +- Web search is optional and should only use generic Go concepts. -## KataGo Runtime +## Community -KataSensei first looks for a bundled runtime: +Join the QQ group for discussion, feedback, and collaboration: ```text -data/katago/ - bin/-/katago - models/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz - models/kata1-zhizi-b28c512nbt-muonfd2.bin.gz +1030632742 ``` -Large KataGo binaries and model files are intentionally not committed to Git. See [data/katago/README.md](./data/katago/README.md). - -## Privacy - -- SGFs, reports, settings, and student profiles stay local by default. -- Saved LLM API keys are encrypted through Electron `safeStorage` when available. -- Current-move analysis may send a board screenshot, KataGo JSON, and selected knowledge cards to the configured LLM endpoint. -- Web search is optional and must use generic Go concepts only. - -## Contributing - -Read [CONTRIBUTING.md](./CONTRIBUTING.md) and [docs/TEACHER_AGENT.md](./docs/TEACHER_AGENT.md) before changing teacher-agent behavior. +Issues and pull requests are welcome. ## License diff --git a/README_JA.md b/README_JA.md index 996f560..bf58cba 100644 --- a/README_JA.md +++ b/README_JA.md @@ -1,18 +1,65 @@ -# KataSensei +

+ GoMentor logo +

-KataGo の判断力と LLM の説明力を組み合わせ、囲碁学習者が「どこで損をしたか」だけでなく「なぜそうなるのか」まで理解できるようにするデスクトップ復盤アプリです。 +

GoMentor

-[中文](./README.md) | [English](./README_EN.md) | [한국어](./README_KO.md) +

+ AI エディタのように動くデスクトップ囲碁教師。
+ KataGo が局面を評価し、マルチモーダル LLM が学習者にわかる言葉で説明します。 +

+ +

+ Release + Downloads + Stars + License + QQ Group +

+ +

+ 中文 | + English | + 日本語 | + 한국어 | + ไทย | + Tiếng Việt +

+ +

+ GoMentor コミュニティ: QQ 1030632742
+ フィードバック、提案、バグ報告を歓迎します。 +

+ +--- + +GoMentor は、KataGo、棋盤スクリーンショット、ローカル知識カード、学習者プロフィール、マルチモーダル LLM を組み合わせたローカル優先の囲碁学習アプリです。単なるチャット付き棋盤ではなく、局面を調べ、根拠を集め、説明し、練習計画まで作れる AI 囲碁教師を目指しています。 + +## ダウンロード + +公開ベータ版: + +[GoMentor v0.2.0-beta.1](https://github.com/wimi321/GoMentor/releases/tag/v0.2.0-beta.1) + +| プラットフォーム | ダウンロード | +| --- | --- | +| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-arm64.dmg) | +| macOS Intel | [DMG](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-x64.dmg) | +| Windows x64 portable ZIP | [ZIP](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64-portable.zip) | +| Windows x64 installer | [EXE](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64.exe) | + +注意:現在のベータ版は未署名です。macOS Gatekeeper や Windows SmartScreen の警告が表示される場合があります。 ## 主な機能 -- ローカル SGF の読み込み -- Fox/野狐の公開棋譜をニックネームまたは UID から同期 -- KataGo による大きな失着の検出 -- OpenAI 互換 LLM によるわかりやすい解説 -- ローカル保存を前提とした安全な運用 +- Fox/野狐の公開棋譜同期と SGF インポート。 +- Lizzie / KTrain に着想を得た棋盤、候補手、実戦手比較、勝率グラフ。 +- 棋譜を読み込むと KataGo 分析を自動開始。 +- 勝率グラフで手を選ぶと、その局面の分析を自動継続。 +- 多モーダル LLM による現在手、全局、最近 10 局、トレーニング計画の説明。 +- ローカル知識ベースと学習者プロフィール。 -## クイックスタート +## 開発 ```bash pnpm install @@ -20,13 +67,25 @@ python3 -m pip install -r scripts/requirements.txt pnpm dev ``` -## 出力 +```bash +pnpm typecheck +pnpm test +pnpm build +pnpm check +``` + +## プライバシー + +棋譜、設定、レポート、学習者プロフィールは既定で `~/.gomentor` に保存されます。現在手の説明では、ユーザーが設定した LLM エンドポイントへ棋盤画像、KataGo JSON、知識カードの一部を送信する場合があります。 -- `review.md` -- `review.json` +## コミュニティ -保存先: +QQ グループでフィードバックや提案を歓迎しています。 ```text -~/.katasensei/reviews// +1030632742 ``` + +## License + +MIT. See [LICENSE](./LICENSE). diff --git a/README_KO.md b/README_KO.md index 59ea39c..fcc720e 100644 --- a/README_KO.md +++ b/README_KO.md @@ -1,18 +1,65 @@ -# KataSensei +

+ GoMentor logo +

-KataGo 분석과 LLM 설명을 결합해서, 바둑 학습자가 단순히 실수 위치만 보는 것이 아니라 왜 나빴는지와 어떻게 고쳐야 하는지까지 이해할 수 있게 해 주는 데스크톱 복기 앱입니다. +

GoMentor

-[中文](./README.md) | [English](./README_EN.md) | [日本語](./README_JA.md) +

+ AI 에디터처럼 동작하는 데스크톱 바둑 선생님.
+ KataGo는 사실을 판단하고, 멀티모달 LLM은 사람이 이해할 수 있게 설명합니다. +

-## 핵심 기능 +

+ Release + Downloads + Stars + License + QQ Group +

-- 로컬 SGF 불러오기 -- Fox/野狐 공개 기보를 닉네임 또는 UID로 동기화 -- KataGo 기반 핵심 실수 탐지 -- OpenAI 호환 LLM으로 쉬운 해설 생성 -- 로컬 우선 저장 +

+ 中文 | + English | + 日本語 | + 한국어 | + ไทย | + Tiếng Việt +

-## 빠른 시작 +

+ GoMentor 커뮤니티: QQ 1030632742
+ 사용 후기, 제안, 버그 리포트를 환영합니다. +

+ +--- + +GoMentor는 KataGo, 바둑판 스크린샷, 로컬 지식 카드, 학생 프로필, 멀티모달 LLM을 하나의 에이전트형 바둑 선생님으로 묶는 로컬 우선 데스크톱 앱입니다. + +## 다운로드 + +공개 베타: + +[GoMentor v0.2.0-beta.1](https://github.com/wimi321/GoMentor/releases/tag/v0.2.0-beta.1) + +| 플랫폼 | 다운로드 | +| --- | --- | +| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-arm64.dmg) | +| macOS Intel | [DMG](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-x64.dmg) | +| Windows x64 portable ZIP | [ZIP](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64-portable.zip) | +| Windows x64 installer | [EXE](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64.exe) | + +현재 베타 패키지는 서명되지 않았으므로 macOS Gatekeeper 또는 Windows SmartScreen 경고가 표시될 수 있습니다. + +## 주요 기능 + +- Fox/野狐 공개 기보 동기화와 SGF 가져오기. +- Lizzie / KTrain 스타일 바둑판, 후보수, 실전수 비교, 승률 그래프. +- 기보를 불러오면 KataGo 분석을 자동 시작. +- 승률 그래프에서 수순을 선택하면 해당 국면 분석을 자동으로 계속 진행. +- 현재 수, 전체 대국, 최근 10국, 훈련 계획을 LLM이 설명. +- 로컬 지식 베이스와 장기 학생 프로필. + +## 개발 ```bash pnpm install @@ -20,13 +67,25 @@ python3 -m pip install -r scripts/requirements.txt pnpm dev ``` -## 출력 파일 +```bash +pnpm typecheck +pnpm test +pnpm build +pnpm check +``` + +## 개인정보 + +기보, 설정, 리포트, 학생 프로필은 기본적으로 `~/.gomentor`에 저장됩니다. 현재 수 설명은 사용자가 설정한 LLM 엔드포인트로 바둑판 이미지, KataGo JSON, 선택된 지식 카드를 보낼 수 있습니다. -- `review.md` -- `review.json` +## 커뮤니티 -기본 저장 위치: +의견과 제안을 위해 QQ 그룹에 참여해 주세요. ```text -~/.katasensei/reviews// +1030632742 ``` + +## License + +MIT. See [LICENSE](./LICENSE). diff --git a/README_TH.md b/README_TH.md new file mode 100644 index 0000000..da120ed --- /dev/null +++ b/README_TH.md @@ -0,0 +1,91 @@ +

+ GoMentor logo +

+ +

GoMentor

+ +

+ ครูสอนโกะบนเดสก์ท็อปที่ทำงานเหมือน AI editor.
+ KataGo ให้ข้อมูลเชิงวิเคราะห์ ส่วน multimodal LLM ช่วยอธิบายให้ผู้เรียนเข้าใจและนำไปฝึกต่อได้ +

+ +

+ Release + Downloads + Stars + License + QQ Group +

+ +

+ 中文 | + English | + 日本語 | + 한국어 | + ไทย | + Tiếng Việt +

+ +

+ ชุมชน GoMentor: QQ 1030632742
+ ยินดีรับฟังความคิดเห็น ข้อเสนอแนะ และรายงานบั๊ก +

+ +--- + +GoMentor เป็นแอปเดสก์ท็อปแบบ local-first สำหรับผู้เรียนและครูสอนโกะ แอปนี้รวม KataGo, ภาพกระดาน, ฐานความรู้ในเครื่อง, โปรไฟล์ผู้เรียน และ multimodal LLM ให้กลายเป็นครูโกะแบบ agent ที่ช่วยวิเคราะห์และวางแผนการฝึกได้ + +## ดาวน์โหลด + +รุ่น beta สาธารณะ: + +[GoMentor v0.2.0-beta.1](https://github.com/wimi321/GoMentor/releases/tag/v0.2.0-beta.1) + +| แพลตฟอร์ม | ดาวน์โหลด | +| --- | --- | +| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-arm64.dmg) | +| macOS Intel | [DMG](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-x64.dmg) | +| Windows x64 portable ZIP | [ZIP](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64-portable.zip) | +| Windows x64 installer | [EXE](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64.exe) | + +หมายเหตุ: beta นี้ยังไม่ได้ signed/notarized บน macOS และยังไม่ได้ code-signed บน Windows จึงอาจมีคำเตือนจากระบบปฏิบัติการ + +## ความสามารถหลัก + +- ซิงก์棋谱สาธารณะจาก Fox/野狐 และนำเข้า SGF +- กระดานสไตล์ Lizzie / KTrain พร้อม candidate moves, played-move comparison และ winrate timeline +- โหลดเกมแล้วเริ่มวิเคราะห์ด้วย KataGo อัตโนมัติ +- เลือกตาเดินบนกราฟแล้ววิเคราะห์ตำแหน่งนั้นต่ออัตโนมัติ +- ครู AI อธิบายตาปัจจุบัน วิเคราะห์ทั้งเกม วิเคราะห์ 10 เกมล่าสุด และสร้างแผนฝึก +- ฐานความรู้ในเครื่องและโปรไฟล์ผู้เรียนระยะยาว + +## พัฒนาในเครื่อง + +```bash +pnpm install +python3 -m pip install -r scripts/requirements.txt +pnpm dev +``` + +```bash +pnpm typecheck +pnpm test +pnpm build +pnpm check +``` + +## ความเป็นส่วนตัว + +棋谱, รายงาน, การตั้งค่า และโปรไฟล์ผู้เรียนจะถูกเก็บไว้ที่ `~/.gomentor` โดยค่าเริ่มต้น การวิเคราะห์ตาปัจจุบันอาจส่งภาพกระดาน, KataGo JSON และ knowledge cards บางส่วนไปยัง LLM endpoint ที่ผู้ใช้ตั้งค่าไว้ + +## ชุมชน + +ยินดีต้อนรับทุกคนเข้ากลุ่ม QQ เพื่อแลกเปลี่ยนความคิดเห็นและช่วยกันพัฒนา: + +```text +1030632742 +``` + +## License + +MIT. See [LICENSE](./LICENSE). diff --git a/README_VI.md b/README_VI.md new file mode 100644 index 0000000..ab66041 --- /dev/null +++ b/README_VI.md @@ -0,0 +1,91 @@ +

+ GoMentor logo +

+ +

GoMentor

+ +

+ Một giáo viên cờ vây trên desktop, hoạt động theo phong cách AI editor.
+ KataGo đưa ra dữ liệu phân tích, multimodal LLM chuyển dữ liệu đó thành lời giảng dễ hiểu. +

+ +

+ Release + Downloads + Stars + License + QQ Group +

+ +

+ 中文 | + English | + 日本語 | + 한국어 | + ไทย | + Tiếng Việt +

+ +

+ Cộng đồng GoMentor: QQ 1030632742
+ Chào mừng góp ý, báo lỗi và cùng hoàn thiện giáo viên cờ vây AI. +

+ +--- + +GoMentor là ứng dụng desktop local-first cho người học và giáo viên cờ vây. Ứng dụng kết hợp KataGo, ảnh bàn cờ, bộ thẻ kiến thức cục bộ, hồ sơ học viên dài hạn và multimodal LLM thành một giáo viên cờ vây có thể tự chạy công cụ và giải thích kết quả. + +## Tải xuống + +Bản beta công khai: + +[GoMentor v0.2.0-beta.1](https://github.com/wimi321/GoMentor/releases/tag/v0.2.0-beta.1) + +| Nền tảng | Tải xuống | +| --- | --- | +| macOS Apple Silicon | [DMG](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-arm64.dmg) | +| macOS Intel | [DMG](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-x64.dmg) | +| Windows x64 portable ZIP | [ZIP](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64-portable.zip) | +| Windows x64 installer | [EXE](https://github.com/wimi321/GoMentor/releases/download/v0.2.0-beta.1/GoMentor-0.2.0-beta.1-win-x64.exe) | + +Lưu ý: bản beta hiện chưa được ký và notarize trên macOS, cũng chưa được code-sign trên Windows, vì vậy hệ điều hành có thể hiển thị cảnh báo bảo mật. + +## Tính năng chính + +- Đồng bộ ván công khai từ Fox/野狐 và nhập SGF. +- Bàn cờ lấy cảm hứng từ Lizzie / KTrain với candidate moves, so sánh nước thực chiến và biểu đồ winrate. +- Tự động bắt đầu phân tích KataGo sau khi tải ván cờ. +- Khi chọn một nước trên biểu đồ, ứng dụng tự động tiếp tục phân tích vị trí đó. +- Giáo viên AI có thể phân tích nước hiện tại, toàn bộ ván, 10 ván gần nhất và tạo kế hoạch luyện tập. +- Bộ kiến thức cục bộ và hồ sơ học viên dài hạn. + +## Phát triển + +```bash +pnpm install +python3 -m pip install -r scripts/requirements.txt +pnpm dev +``` + +```bash +pnpm typecheck +pnpm test +pnpm build +pnpm check +``` + +## Quyền riêng tư + +Ván cờ, báo cáo, cài đặt và hồ sơ học viên được lưu mặc định trong `~/.gomentor`. Phân tích nước hiện tại có thể gửi ảnh bàn cờ, KataGo JSON và một số knowledge cards đến LLM endpoint do người dùng cấu hình. + +## Cộng đồng + +Tham gia nhóm QQ để trao đổi, góp ý và cùng hoàn thiện GoMentor: + +```text +1030632742 +``` + +## License + +MIT. See [LICENSE](./LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 24dd588..2298971 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,16 +1,16 @@ # Security Policy -KataSensei handles local SGFs, student profiles, API keys, board screenshots, and LLM requests. Please treat privacy and local-system safety as product requirements. +GoMentor handles local SGFs, student profiles, API keys, board screenshots, and LLM requests. Please treat privacy and local-system safety as product requirements. ## Supported Versions -KataSensei is currently in early public development. Security fixes target the latest `main` branch until stable releases begin. +GoMentor is currently in early public development. Security fixes target the latest `main` branch until stable releases begin. ## Reporting a Vulnerability Please report vulnerabilities privately through GitHub Security Advisories on the repository: -https://github.com/wimi321/katasensei/security/advisories +https://github.com/wimi321/GoMentor/security/advisories Do not open a public issue for vulnerabilities that include exploit details, private SGFs, API keys, personal data, or local paths. @@ -23,4 +23,4 @@ Do not open a public issue for vulnerabilities that include exploit details, pri ## Local Execution -KataSensei runs local processes such as KataGo. Changes that alter binary discovery, model loading, local file access, shell execution, or automatic installation should receive extra review. +GoMentor runs local processes such as KataGo. Changes that alter binary discovery, model loading, local file access, shell execution, or automatic installation should receive extra review. diff --git a/assets/demo-blunder.sgf b/assets/demo-blunder.sgf index 89882b1..03caf3f 100644 --- a/assets/demo-blunder.sgf +++ b/assets/demo-blunder.sgf @@ -1,2 +1,2 @@ -(;GM[1]FF[4]CA[UTF-8]AP[KataSensei]SZ[9]KM[7.5]PB[StudentBot]PW[CoachBot]DT[2026-03-31]RE[W+R]GN[KataSensei Blunder Demo] +(;GM[1]FF[4]CA[UTF-8]AP[GoMentor]SZ[9]KM[7.5]PB[StudentBot]PW[CoachBot]DT[2026-03-31]RE[W+R]GN[GoMentor Blunder Demo] ;B[ee];W[de];B[];W[dd];B[];W[cd];B[cf];W[ce];B[];W[df];B[fe];W[ed]) diff --git a/assets/demo-game.sgf b/assets/demo-game.sgf index e9fd097..0401881 100644 --- a/assets/demo-game.sgf +++ b/assets/demo-game.sgf @@ -1,4 +1,4 @@ -(;GM[1]FF[4]CA[UTF-8]AP[KataSensei]SZ[9]KM[7.5]PB[StudentBot]PW[CoachBot]DT[2026-03-31]RE[W+R]GN[KataSensei Demo Game] +(;GM[1]FF[4]CA[UTF-8]AP[GoMentor]SZ[9]KM[7.5]PB[StudentBot]PW[CoachBot]DT[2026-03-31]RE[W+R]GN[GoMentor Demo Game] ;B[ee];W[de];B[ed];W[dd];B[cf];W[fe];B[cc];W[fg];B[gc];W[cd] ;B[hc];W[ce];B[he];W[bf];B[gg];W[be];B[ge];W[bc];B[hd];W[bd] ;B[if];W[ch];B[ih];W[dh];B[ig];W[eh];B[gh];W[fh];B[gi];W[ei]) diff --git a/build/entitlements.mac.inherit.plist b/build/entitlements.mac.inherit.plist new file mode 100644 index 0000000..f2eb2ec --- /dev/null +++ b/build/entitlements.mac.inherit.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 0000000..f2eb2ec --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/data/katago/README.md b/data/katago/README.md index 81df4cd..92fe6a4 100644 --- a/data/katago/README.md +++ b/data/katago/README.md @@ -1,6 +1,6 @@ # KataGo Runtime Layout -KataSensei looks for a bundled KataGo runtime here when packaging the app: +GoMentor looks for a bundled KataGo runtime here when packaging the app: - `bin/-/katago` - `models/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz` diff --git a/data/katago/manifest.json b/data/katago/manifest.json new file mode 100644 index 0000000..3de6a6e --- /dev/null +++ b/data/katago/manifest.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "defaultModelId": "official-b18-recommended", + "defaultModelFileName": "kata1-b18c384nbt-s9996604416-d4316597426.bin.gz", + "defaultModelDisplayName": "KataGo b18 Recommended", + "modelPath": "models/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz", + "modelSha256": "", + "supportedPlatforms": { + "darwin-arm64": { + "binaryPath": "bin/darwin-arm64/katago", + "sha256": "" + }, + "darwin-x64": { + "binaryPath": "bin/darwin-x64/katago", + "sha256": "" + }, + "win32-x64": { + "binaryPath": "bin/win32-x64/katago.exe", + "sha256": "" + } + }, + "notes": [ + "Actual binary/model files are release assets or Git LFS assets, not ordinary Git blobs.", + "prepare_katago_assets.mjs can copy them from local paths or CI-provided artifact directories." + ] +} diff --git a/data/knowledge/p0-cards.json b/data/knowledge/p0-cards.json new file mode 100644 index 0000000..f8615e2 --- /dev/null +++ b/data/knowledge/p0-cards.json @@ -0,0 +1,1075 @@ +[ + { + "id": "direction_local_gain_global_loss", + "title": "局部便宜不等于全局好", + "kind": "concept", + "phase": [ + "opening", + "middlegame" + ], + "errorTypes": [ + "direction" + ], + "tags": [ + "方向感", + "大局观" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "很多失误不是局部算错,而是把注意力放在了不该优先的地方。", + "coachShort": "你这手的问题不在局部有没有便宜,而在全局方向走反了。", + "coachLong": "当对方外围已经厚,而你继续在局部小便宜上纠缠时,常会出现局部似乎没亏、全局其实更差。", + "drill": "复盘时先回答:盘上最大点在哪?对方最想走哪?我这手是在抢地、抢先手还是补效率?", + "related": [] + }, + { + "id": "direction_wrong_side", + "title": "方向错误比算错更常见", + "kind": "error_type", + "phase": [ + "opening", + "middlegame" + ], + "errorTypes": [ + "direction" + ], + "tags": [ + "方向感", + "判断" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "业余棋中很多大损不是读秒内算错,而是从一开始就选错了战场。", + "coachShort": "这不是简单算错,是方向判断先出了问题。", + "coachLong": "方向错误会让后续每一步都在低效区域补救,KataGo 推荐远离局部时通常要先怀疑方向。", + "drill": "每盘只找一个方向错误,写下当时为什么被局部吸引。", + "related": [] + }, + { + "id": "direction_thickness_outside", + "title": "有厚走外,没厚走实", + "kind": "position_pattern", + "phase": [ + "opening", + "middlegame" + ], + "errorTypes": [ + "direction" + ], + "tags": [ + "厚薄", + "方向感" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "厚势的价值在于向外控制和制造未来收益。", + "coachShort": "你有厚就该把力量兑现到外面,而不是继续在小地方打转。", + "coachLong": "有厚还回头补小处,会把厚势变成没有后续的重棋。", + "drill": "挑三盘自己有厚却没往外走的例子,标注当时应该看的外部点。", + "related": [] + }, + { + "id": "direction_against_thickness", + "title": "不要逆着对方厚势行棋", + "kind": "position_pattern", + "phase": [ + "opening", + "middlegame" + ], + "errorTypes": [ + "direction" + ], + "tags": [ + "厚势", + "安全" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "逆着对方厚势行棋,容易让自己的棋变薄并被连续攻击。", + "coachShort": "这里不该往对方厚的地方硬撞。", + "coachLong": "对方厚的一侧意味着对方后续手段多,你若在那里开战,局部看似积极,整体风险很高。", + "drill": "复盘时把对方厚势标出来,问自己有没有主动走进对方力量范围。", + "related": [] + }, + { + "id": "direction_big_point_priority", + "title": "拆边与抢大场的优先级", + "kind": "concept", + "phase": [ + "opening" + ], + "errorTypes": [ + "direction" + ], + "tags": [ + "布局", + "大场" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "布局阶段要比较拆边、挂角、守角、逼迫的全局价值。", + "coachShort": "这手小处处理过早,盘面更大的方向被放掉了。", + "coachLong": "开局阶段的失分常来自先后顺序错误,不是招法本身完全不能下。", + "drill": "复盘前 50 手时,每手只问:这是不是当时盘上最大方向?", + "related": [] + }, + { + "id": "direction_advantage_reduce_risk", + "title": "优势局先压缩风险", + "kind": "training", + "phase": [ + "middlegame", + "endgame" + ], + "errorTypes": [ + "advantage" + ], + "tags": [ + "优势局", + "风险" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "优势局最重要的是避免给对方制造复杂化机会。", + "coachShort": "你已经领先了,这里不需要再主动制造新战场。", + "coachLong": "优势时应优先补最大风险点,而不是追求局部继续扩大收益。", + "drill": "把优势局错手单独记录,标注是贪、缓,还是开了不必要的战斗。", + "related": [] + }, + { + "id": "thickness_not_territory", + "title": "厚不是地,是未来的力量", + "kind": "concept", + "phase": [ + "opening", + "middlegame" + ], + "errorTypes": [ + "thickness" + ], + "tags": [ + "厚薄", + "外势" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "厚势不能只按眼前实地计算,它是未来攻击、扩张、压迫的资本。", + "coachShort": "这块厚棋的价值不在这里马上围多少地,而在后续能压迫哪里。", + "coachLong": "如果把厚势只当成局部实地,就容易补得过重、方向走小。", + "drill": "每次复盘厚势时写下:它下一步能影响哪三处?", + "related": [] + }, + { + "id": "thickness_thin_no_fight", + "title": "薄棋不该主动找战斗", + "kind": "error_type", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "thickness" + ], + "tags": [ + "薄棋", + "战斗" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "自己薄时主动开战,经常会把局面复杂化到自己无法承受。", + "coachShort": "你这块还没安定,不该主动把战斗升级。", + "coachLong": "KataGo 若推荐先整形或脱先,通常说明继续战斗的收益不足以覆盖自身风险。", + "drill": "遇到薄棋先列出:逃、补、弃、转换四个选择。", + "related": [] + }, + { + "id": "attack_to_profit", + "title": "攻击的目的不是杀,而是得利", + "kind": "concept", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "attack" + ], + "tags": [ + "攻击", + "转换" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "攻击不是一定要杀棋,而是通过威胁获得实地、厚势或先手。", + "coachShort": "这里不要只想着杀,攻击的目标应该是顺势得利。", + "coachLong": "业余常见问题是把攻击理解成追杀,导致自己也走重。", + "drill": "复盘攻击时写下这次攻击实际想得到什么:地、厚、先手还是破坏。", + "related": [] + }, + { + "id": "attack_not_heavy", + "title": "攻击时别把自己走重", + "kind": "error_type", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "attack" + ], + "tags": [ + "攻击", + "重棋" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "攻击若每手都贴身追,可能把自己的棋也走成重棋。", + "coachShort": "你在攻击对方,但自己的棋也越来越重了。", + "coachLong": "好的攻击应该让对方难受,同时保持自己灵活,而不是双方一起变重。", + "drill": "找出一盘追杀失败的棋,标注哪一步开始自己也走重。", + "related": [] + }, + { + "id": "attack_borrow_force", + "title": "看到对方重,要先想借力", + "kind": "position_pattern", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "attack" + ], + "tags": [ + "攻击", + "借力" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "对方重棋是可被利用的资源,未必需要马上吃掉。", + "coachShort": "对方已经重了,你可以借它来扩张,而不是急着硬杀。", + "coachLong": "利用对方重棋可以获得外围、先手或官子价值。", + "drill": "练习把重棋旁边的可借力方向圈出来。", + "related": [] + }, + { + "id": "thin_shape_first", + "title": "看到自己薄,要先想整形", + "kind": "position_pattern", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "thickness" + ], + "tags": [ + "整形", + "薄棋" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "薄棋优先目标通常是整形和降低被攻击价值。", + "coachShort": "这手应该先让自己的棋变轻、变安全。", + "coachLong": "薄棋盲目抢利益会给对方攻击目标。", + "drill": "每盘记录一次薄棋处理,判断是补、逃、弃还是转换。", + "related": [] + }, + { + "id": "shape_bad_shape_cost", + "title": "愚形不是不能下,是代价高", + "kind": "concept", + "phase": [ + "opening", + "middlegame" + ], + "errorTypes": [ + "shape" + ], + "tags": [ + "棋形", + "效率" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "愚形的问题是效率低,会让后续多花手数补救。", + "coachShort": "这手不是绝对不能下,而是效率太低。", + "coachLong": "KataGo 对形状的惩罚往往体现在后续主动权和补棋负担上。", + "drill": "复盘时把自己最难看的三个形标出来,问是否有更轻的形。", + "related": [] + }, + { + "id": "shape_connect_cut_efficiency", + "title": "粘与断的效率判断", + "kind": "concept", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "shape" + ], + "tags": [ + "棋形", + "连接" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "粘和断不是本能选择,要比较连接价值与先手价值。", + "coachShort": "这里不能只凭“怕断”就粘,要看粘完有没有后续价值。", + "coachLong": "过度连接常会损失先手;盲目断又可能让自己变薄。", + "drill": "做 10 个局面,只判断该粘、该断,还是该脱先。", + "related": [] + }, + { + "id": "shape_best_fix_point", + "title": "补棋要补在最有效的点", + "kind": "position_pattern", + "phase": [ + "middlegame", + "endgame" + ], + "errorTypes": [ + "shape" + ], + "tags": [ + "补棋", + "效率" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "补棋不是越厚越好,而是用最少手数解决最大风险。", + "coachShort": "你这手补到了,但补得不够有效。", + "coachLong": "低效补棋会让你花一手却没有明显降低风险。", + "drill": "复盘每个补棋点,问它解决了哪一个具体问题。", + "related": [] + }, + { + "id": "shape_overdefense", + "title": "重复补强是常见失分点", + "kind": "error_type", + "phase": [ + "middlegame", + "endgame" + ], + "errorTypes": [ + "shape" + ], + "tags": [ + "补强", + "过缓" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "已经安全的地方继续补,会把主动权送给对手。", + "coachShort": "这里已经够安全了,再补就是过缓。", + "coachLong": "KataGo 常会惩罚重复补强,因为它放掉了盘上更急的点。", + "drill": "标记三处自己连续补同一块的棋,判断第二手是否必要。", + "related": [] + }, + { + "id": "shape_good_shape_future", + "title": "好形不是漂亮,是有后续", + "kind": "concept", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "shape" + ], + "tags": [ + "棋形", + "后续" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "好形的标准是效率、弹性和后续,不只是看起来漂亮。", + "coachShort": "这手看着规矩,但后续不够好。", + "coachLong": "真正的好形应同时减少风险、保留主动和制造后续。", + "drill": "复盘时对每个形问:它下一手有什么后续?", + "related": [] + }, + { + "id": "shape_before_reading", + "title": "棋形问题常常先于算路", + "kind": "error_type", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "shape" + ], + "tags": [ + "棋形", + "算路" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "很多战斗读不清,其实是前面形状已埋下低效。", + "coachShort": "不是你这一步才读错,而是前面形状让你很难下。", + "coachLong": "形状差会让后续变化都变得被动。", + "drill": "找到死活前 5 手,判断是哪一步形状开始变差。", + "related": [] + }, + { + "id": "fight_light_heavy_first", + "title": "先分清轻重,再决定战斗强度", + "kind": "concept", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "fight" + ], + "tags": [ + "战斗", + "轻重" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "战斗前先判断哪些棋必须救,哪些可以弃。", + "coachShort": "你这里的问题是没先分清哪块棋重。", + "coachLong": "不分轻重就开战,容易两边都想要,最后全都被拖住。", + "drill": "复盘战斗时把每块棋标成重棋、轻棋、可弃子。", + "related": [] + }, + { + "id": "fight_liberty_count", + "title": "对杀前先数气", + "kind": "error_type", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "fight" + ], + "tags": [ + "对杀", + "读棋" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "对杀判断必须建立在数气和先后手上,不能凭感觉。", + "coachShort": "这里进入对杀前,需要先冷静数气。", + "coachLong": "很多对杀错觉来自忽略紧气顺序或先手做眼。", + "drill": "每天做 5 道只数气不落子的对杀判断。", + "related": [] + }, + { + "id": "fight_sacrifice_conversion", + "title": "打架时先看有没有弃子转换", + "kind": "position_pattern", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "fight" + ], + "tags": [ + "弃子", + "转换" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "战斗中不是每个子都要救,弃子换势常比硬救更好。", + "coachShort": "这里可以考虑弃掉小部分,换取外部主动。", + "coachLong": "硬救小块可能让全局变重;转换能把局部损失变成全局收益。", + "drill": "复盘时列出每次战斗的可弃子方案。", + "related": [] + }, + { + "id": "fight_split_priority", + "title": "分断后先想哪块更重要", + "kind": "concept", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "fight" + ], + "tags": [ + "分断", + "轻重" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "分断后不能两边平均用力,要先确定主次。", + "coachShort": "你被分断后,两边都想救,结果都变重。", + "coachLong": "优秀处理通常是保重要一边,另一边轻处理或转换。", + "drill": "找三处被分断局面,只写优先保护哪一块。", + "related": [] + }, + { + "id": "fight_cut_chance_not_must", + "title": "有冲断机会不代表必须开战", + "kind": "error_type", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "fight" + ], + "tags": [ + "冲断", + "克制" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "看到断点要判断收益和风险,不是有断就断。", + "coachShort": "这手断得很积极,但风险大于收益。", + "coachLong": "冲断若不能带来明确收益,反而会制造自己的薄味。", + "drill": "复盘每次冲断,写下断成功后实际得到什么。", + "related": [] + }, + { + "id": "fight_two_sides_problem", + "title": "战斗中最怕两边都想要", + "kind": "error_type", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "fight" + ], + "tags": [ + "战斗", + "取舍" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "战斗中最常见的大亏是没有取舍,什么都想保。", + "coachShort": "你这里最大的问题是没有取舍。", + "coachLong": "两边都想要会让棋越来越重,最后被对手连续利用。", + "drill": "训练时强制给每场战斗写一句:这块我可以弃吗?", + "related": [] + }, + { + "id": "life_alive_vs_settled", + "title": "活棋与净活的区别", + "kind": "concept", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "life_death" + ], + "tags": [ + "死活", + "净活" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "活了不等于局面好,净活与勉强活价值不同。", + "coachShort": "这块虽然能活,但活得太苦,外面损失太大。", + "coachLong": "KataGo 有时不急着做活,是因为可以更有效地净活或转换。", + "drill": "复盘死活时区分:净活、苦活、劫活、可弃。", + "related": [] + }, + { + "id": "life_sente_eye", + "title": "先手做眼和后手做眼价值差", + "kind": "concept", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "life_death" + ], + "tags": [ + "死活", + "先后手" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "同样做眼,先手和后手价值完全不同。", + "coachShort": "这手做活了,但把先手送掉了。", + "coachLong": "死活处理中最重要的不只是活不活,还包括谁拿到下一手。", + "drill": "做死活题时额外标注最后一步是先手还是后手。", + "related": [] + }, + { + "id": "life_misread_sente", + "title": "死活误判常来自忽略先手", + "kind": "error_type", + "phase": [ + "middlegame", + "endgame" + ], + "errorTypes": [ + "life_death" + ], + "tags": [ + "死活", + "先手" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "很多死活错觉来自只看局部形,不看对方先手手段。", + "coachShort": "你这里误判了对方的先手收紧。", + "coachLong": "死活判断要把对方的逼迫手、紧气手和劫材价值一起看。", + "drill": "每道死活题先找对方最强先手,再判断。", + "related": [] + }, + { + "id": "tesuji_efficiency", + "title": "手筋是效率最高的一手", + "kind": "concept", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "tesuji" + ], + "tags": [ + "手筋", + "效率" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "手筋不是炫技,而是用最高效率解决局部问题。", + "coachShort": "这里需要找效率最高的处理,而不是普通补棋。", + "coachLong": "KataGo 推荐的看似奇怪点,很多是因为它同时解决多个问题。", + "drill": "做手筋题时写下这手同时解决了哪两个问题。", + "related": [] + }, + { + "id": "tesuji_common_frame", + "title": "断点扳挤靠的教学框架", + "kind": "concept", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "tesuji" + ], + "tags": [ + "手筋", + "局部" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "局部手筋常围绕断点、扳、挤、靠、顶这些效率手展开。", + "coachShort": "这里先别急着补,看看有没有利用断点的手筋。", + "coachLong": "手筋训练的目标是提升效率感,而不是背招法。", + "drill": "复盘时把 KataGo 第一推荐中的局部手筋类型标出来。", + "related": [] + }, + { + "id": "life_training_transfer", + "title": "死活题怎样迁移到实战", + "kind": "training", + "phase": [ + "middlegame" + ], + "errorTypes": [ + "life_death" + ], + "tags": [ + "训练", + "死活" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "死活训练要迁移到实战,必须练识别触发场景。", + "coachShort": "不是只做题,而是要知道什么时候该进入死活计算。", + "coachLong": "实战死活通常和全局取舍绑定,不是孤立题目。", + "drill": "复盘每次死活失误时写下:我为什么没意识到这里需要精算?", + "related": [] + }, + { + "id": "endgame_not_last", + "title": "官子不是最后才重要", + "kind": "concept", + "phase": [ + "middlegame", + "endgame" + ], + "errorTypes": [ + "endgame" + ], + "tags": [ + "官子", + "价值" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "官子意识从中盘后半就开始影响胜负。", + "coachShort": "这里不是小官子,它已经影响全盘收束。", + "coachLong": "很多优势局被追上,是因为中盘后半就开始放小价值。", + "drill": "复盘 120 手后每手估一个价值,训练官子感觉。", + "related": [] + }, + { + "id": "endgame_sente_double_sente", + "title": "先手官子和双先判断", + "kind": "concept", + "phase": [ + "endgame" + ], + "errorTypes": [ + "endgame" + ], + "tags": [ + "官子", + "先手" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "官子价值不能只看目数,还要看先后手。", + "coachShort": "这手价值不只在目数,还在它是不是先手。", + "coachLong": "双先、单先、后手的价值差会改变收束顺序。", + "drill": "做官子题时先分类:双先、单先、后手。", + "related": [] + }, + { + "id": "endgame_big_before_small", + "title": "大官子优先于小便宜", + "kind": "error_type", + "phase": [ + "endgame" + ], + "errorTypes": [ + "endgame" + ], + "tags": [ + "官子", + "价值" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "收束阶段贪小便宜会错过真正的大官子。", + "coachShort": "你这手拿了小便宜,但放掉了更大的官子。", + "coachLong": "官子顺序错误通常看似每手都合理,合起来却亏很多。", + "drill": "每盘官子阶段挑 5 手,重新按价值排序。", + "related": [] + }, + { + "id": "endgame_advantage_throwback", + "title": "优势局最怕官子送回去", + "kind": "error_type", + "phase": [ + "endgame" + ], + "errorTypes": [ + "endgame" + ], + "tags": [ + "优势局", + "官子" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "优势局进入官子后,要避免连续小亏把优势送回去。", + "coachShort": "你前面优势够,但官子阶段开始送回去了。", + "coachLong": "领先时的官子原则是稳、准、少冒险。", + "drill": "找一盘优势被追近的棋,只复盘最后 80 手。", + "related": [] + }, + { + "id": "endgame_value_sense", + "title": "官子差往往是价值感弱", + "kind": "training", + "phase": [ + "endgame" + ], + "errorTypes": [ + "endgame" + ], + "tags": [ + "训练", + "官子" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "官子弱常常不是不会算,而是没有价值排序习惯。", + "coachShort": "这里的问题不是不会下,而是没意识到它值这么多。", + "coachLong": "官子训练重点是估值和顺序,而不是记具体手段。", + "drill": "每天选 10 个官子点,先估值再看答案。", + "related": [] + }, + { + "id": "endgame_sente_sweep", + "title": "收束阶段防先手搜刮", + "kind": "position_pattern", + "phase": [ + "endgame" + ], + "errorTypes": [ + "endgame" + ], + "tags": [ + "官子", + "防守" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "收束时要警惕对方用先手连续搜刮。", + "coachShort": "这里要先防对方的先手收束链。", + "coachLong": "如果放任对方连续先手,局部每次亏不大,但全盘会被持续压缩。", + "drill": "复盘官子时标出对方连续先手的链条。", + "related": [] + }, + { + "id": "advantage_no_complexity", + "title": "优势局不要主动复杂化", + "kind": "error_type", + "phase": [ + "middlegame", + "endgame" + ], + "errorTypes": [ + "advantage" + ], + "tags": [ + "优势局", + "风险" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "领先时主动制造复杂局面,是最常见的翻车方式。", + "coachShort": "你已经领先,不需要主动把局面变复杂。", + "coachLong": "优势局的最佳策略往往是减少不确定性,而不是证明自己还能赢更多。", + "drill": "每盘优势局记录一次自己是否主动开新战场。", + "related": [] + }, + { + "id": "advantage_fix_danger", + "title": "优势局补最危险处", + "kind": "position_pattern", + "phase": [ + "middlegame", + "endgame" + ], + "errorTypes": [ + "advantage" + ], + "tags": [ + "优势局", + "安全" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "优势局该补的不是最大收益点,而是最大风险点。", + "coachShort": "这里该先处理风险,而不是继续抢收益。", + "coachLong": "领先时只要最大风险消失,对手就很难逆转。", + "drill": "把盘面风险点按危险程度排序,不按目数排序。", + "related": [] + }, + { + "id": "behind_not_every_move_desperate", + "title": "劣势局不要每手搏命", + "kind": "error_type", + "phase": [ + "middlegame", + "endgame" + ], + "errorTypes": [ + "disadvantage" + ], + "tags": [ + "劣势局", + "逆转" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "落后时每手都搏命,只会更快崩盘。", + "coachShort": "你落后了,但这里不能每手都硬拼。", + "coachLong": "劣势局要找结构性机会,而不是随机制造战斗。", + "drill": "复盘劣势局时写下真正的逆转点在哪里。", + "related": [] + }, + { + "id": "behind_structural_chance", + "title": "落后找结构性逆转点", + "kind": "position_pattern", + "phase": [ + "middlegame", + "endgame" + ], + "errorTypes": [ + "disadvantage" + ], + "tags": [ + "劣势局", + "逆转" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "真正的逆转来自对方薄味、劫争、大龙、收束漏洞等结构性机会。", + "coachShort": "这里需要找对方结构里的漏洞,而不是盲目冲。", + "coachLong": "劣势局也要有计划地制造复杂,而不是无目标乱战。", + "drill": "每盘劣势局列出三个可能逆转资源。", + "related": [] + }, + { + "id": "big_lead_greed", + "title": "大优最怕贪", + "kind": "error_type", + "phase": [ + "middlegame", + "endgame" + ], + "errorTypes": [ + "advantage" + ], + "tags": [ + "优势局", + "贪心" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "大优时继续贪局部利益,会给对手反击目标。", + "coachShort": "你这手太贪,给了对方制造复杂的机会。", + "coachLong": "大优时少赢一点没关系,重要是不给反击点。", + "drill": "优势局复盘时标记所有“想再多拿一点”的手。", + "related": [] + }, + { + "id": "big_behind_chaos", + "title": "大劣最怕乱", + "kind": "error_type", + "phase": [ + "middlegame", + "endgame" + ], + "errorTypes": [ + "disadvantage" + ], + "tags": [ + "劣势局", + "混乱" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "大劣时乱战不等于有机会,常常只是加速输棋。", + "coachShort": "你这里不是在制造机会,而是在乱。", + "coachLong": "劣势时也要选择对方最难处理的矛盾点,而不是到处出手。", + "drill": "复盘大劣局,找出唯一最有希望的复杂化点。", + "related": [] + }, + { + "id": "review_turning_points_first", + "title": "单盘复盘先找转折点", + "kind": "review_method", + "phase": [ + "opening", + "middlegame", + "endgame" + ], + "errorTypes": [], + "tags": [ + "复盘", + "方法" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "复盘不该逐手念经,先找到真正改变胜负的转折点。", + "coachShort": "这盘先别每手都看,先抓最大转折点。", + "coachLong": "高效复盘要从最大损失点开始,再回看导致它的前因。", + "drill": "每盘只记录 3 个转折点:开局、中盘、官子。", + "related": [] + }, + { + "id": "review_one_or_two_issues", + "title": "一盘棋只抓一两个主问题", + "kind": "review_method", + "phase": [ + "opening", + "middlegame", + "endgame" + ], + "errorTypes": [], + "tags": [ + "复盘", + "训练" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "一次复盘抓太多问题,反而难以改进。", + "coachShort": "这盘最重要的不是所有错手,而是一个主问题。", + "coachLong": "老师应该帮助学生聚焦最能涨棋的问题。", + "drill": "复盘结束只写一句:下盘最优先改什么。", + "related": [] + }, + { + "id": "review_repeated_errors", + "title": "连续三盘同类错误才重点训练", + "kind": "review_method", + "phase": [ + "opening", + "middlegame", + "endgame" + ], + "errorTypes": [], + "tags": [ + "画像", + "训练" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "偶发失误不一定是长期弱点,重复出现才需要专项训练。", + "coachShort": "这个问题如果最近几盘都出现,就该进入训练重点。", + "coachLong": "学生画像要区分偶发错误和稳定模式。", + "drill": "最近 5 盘按错误类型做计数,超过 3 次才列为重点。", + "related": [] + }, + { + "id": "review_random_vs_stable", + "title": "区分偶发失误和稳定弱点", + "kind": "review_method", + "phase": [ + "opening", + "middlegame", + "endgame" + ], + "errorTypes": [], + "tags": [ + "画像", + "复盘" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "稳定弱点需要训练,偶发失误需要提醒但不必过度放大。", + "coachShort": "这手可能是偶发,但要看最近有没有重复。", + "coachLong": "顶级老师不会把每个错手都当成长期问题。", + "drill": "复盘报告里把问题分成偶发和稳定两栏。", + "related": [] + }, + { + "id": "review_mistake_book", + "title": "错题本要围绕模式", + "kind": "training", + "phase": [ + "opening", + "middlegame", + "endgame" + ], + "errorTypes": [], + "tags": [ + "错题本", + "训练" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "错题本不是收集局面,而是收集自己反复犯错的模式。", + "coachShort": "这类局面值得进错题本,因为它代表你的稳定模式。", + "coachLong": "只存局面不总结模式,很难真正涨棋。", + "drill": "每条错题必须写:我当时为什么会这样想。", + "related": [] + }, + { + "id": "review_weekly_plan", + "title": "一周训练围绕薄弱点", + "kind": "training", + "phase": [ + "opening", + "middlegame", + "endgame" + ], + "errorTypes": [], + "tags": [ + "训练计划", + "画像" + ], + "katagoSignals": [], + "boardSignals": [], + "summary": "训练计划应围绕最近稳定弱点,而不是随机刷题。", + "coachShort": "接下来一周不要泛泛练,围绕这个主问题安排。", + "coachLong": "画像统计出的高频问题,应该转成具体训练任务。", + "drill": "一周只定一个主题:三盘复盘、二十题专项、一次总结。", + "related": [] + } +] \ No newline at end of file diff --git a/data/knowledge/pattern-cards.json b/data/knowledge/pattern-cards.json new file mode 100644 index 0000000..6d68bcd --- /dev/null +++ b/data/knowledge/pattern-cards.json @@ -0,0 +1,1992 @@ +[ + { + "id": "joseki_star_33_invasion_double_hane", + "title": "星位点三三:双扳保角", + "category": "joseki", + "patternType": "corner_joseki", + "phase": ["opening", "middlegame"], + "levels": ["beginner", "intermediate", "advanced", "dan"], + "regions": ["corner"], + "tags": ["星位", "点三三", "三三侵入", "双扳", "定式", "角部"], + "aliases": ["4-4 3-3 invasion", "星位三三", "三三双扳", "double hane"], + "boardSignals": ["corner", "4-4", "3-3", "second-line", "contact"], + "triggers": { + "moveFeatures": ["3-3"], + "candidateFeatures": ["3-3"], + "pvFeatures": ["second-line", "corner"], + "contextTags": ["星位", "三三", "定式", "角部"], + "minMoveNumber": 1, + "maxMoveNumber": 90 + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["4-4", "3-3", "hane", "connect", "hane"], + "gtpExamples": ["Q16 R17", "D16 C17", "Q4 R3", "D4 C3"], + "symmetry": "corner" + }, + "variations": [ + { + "name": "双扳保角", + "mainLine": "侵入三三后,外侧连续扳,守角一方保住角部实地,侵入方取得外势和先手可能。", + "whenToChoose": "守角方重视角部实地,外侧不急着抢先手。", + "warning": "方向错误会让外势朝向对方厚处,局部定式对了,全局仍可能亏。" + }, + { + "name": "简明应对", + "mainLine": "按基本手顺收束,避免在角上多交换给对方厚味。", + "whenToChoose": "学生阶段先把基本型下干净,比追复杂变化更重要。" + } + ], + "teaching": { + "recognition": "看到星位角被点三三,第一反应不是背手顺,而是判断外势要朝哪边。", + "correctIdea": "KataGo 若推荐三三,通常说明角部确定实地或破坏对方发展很大。", + "memoryCue": "点三三先问方向:我要角地,还是我要外势和先手?", + "commonMistake": "把三三定式当固定答案,不看旁边双方厚薄和下一处大场。", + "drill": "找 5 个星位点三三局部,只标外势方向,不急着背完整手顺。" + } + }, + { + "id": "joseki_star_33_invasion_flying_press", + "title": "星位点三三:飞压取势", + "category": "joseki", + "patternType": "corner_joseki", + "phase": ["opening", "middlegame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner"], + "tags": ["星位", "点三三", "飞压", "外势", "先手", "定式"], + "aliases": ["三三飞压", "flying press", "星位飞压"], + "boardSignals": ["corner", "4-4", "3-3", "third-line", "influence"], + "triggers": { + "moveFeatures": ["3-3"], + "candidateFeatures": ["3-3", "knight-move"], + "pvFeatures": ["corner", "third-line"], + "contextTags": ["三三", "外势", "先手", "定式"], + "minMoveNumber": 1, + "maxMoveNumber": 100 + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["4-4", "3-3", "flying-press"], + "gtpExamples": ["Q16 R17 P17", "D16 C17 E17"], + "symmetry": "corner" + }, + "variations": [ + { + "name": "飞压取势", + "mainLine": "守角方允许对方拿角,自己取得外势并争取先手。", + "whenToChoose": "外侧有己方配合,或者全局有更大的先手点。", + "warning": "如果外势没有后续目标,飞压会变成空架子。" + }, + { + "name": "保留劫味", + "mainLine": "部分扳粘交换不急着走,保留角部味道给后续利用。", + "whenToChoose": "全局需要先手,且局部暂时没有被杀风险。" + } + ], + "teaching": { + "recognition": "点三三后不一定都要双扳;如果外面价值大,飞压是 AI 很常见的取势思路。", + "correctIdea": "用角部实地换外势和先手,关键是外势有没有攻击对象。", + "memoryCue": "飞压不是便宜,是把角交出去,换外面主动权。", + "commonMistake": "只看到对方拿角,没看到自己外势是否能马上发挥作用。", + "drill": "每次遇到飞压,写下外势后续能攻击哪块棋。" + } + }, + { + "id": "joseki_star_low_approach_small_knight", + "title": "星位小飞挂:小飞应", + "category": "joseki", + "patternType": "corner_joseki", + "phase": ["opening"], + "levels": ["beginner", "intermediate", "advanced", "dan"], + "regions": ["corner"], + "tags": ["星位", "挂角", "小飞挂", "小飞应", "定式", "守角"], + "aliases": ["星位小飞应", "4-4 low approach", "small knight response"], + "boardSignals": ["corner", "4-4", "approach", "knight-move"], + "triggers": { + "moveFeatures": ["approach"], + "candidateFeatures": ["knight-move", "corner"], + "pvFeatures": ["corner"], + "contextTags": ["星位", "挂角", "定式"], + "minMoveNumber": 1, + "maxMoveNumber": 60 + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["4-4", "low-approach", "small-knight-response"], + "gtpExamples": ["Q16 R14 O17", "D16 C14 E17"], + "symmetry": "corner" + }, + "variations": [ + { + "name": "小飞应", + "mainLine": "守角方小飞守住角部,保持轻灵,挂角方通常拆边或转向其他大场。", + "whenToChoose": "想稳住角部,同时不把棋下得太重。", + "warning": "旁边有强弱配合时,夹攻或脱先可能更大。" + }, + { + "name": "脱先抢大场", + "mainLine": "AI 常在被挂角后脱先,承认角部还有味,但先抢全局最大点。", + "whenToChoose": "别处价值明显更大,局部暂时不会崩。" + } + ], + "teaching": { + "recognition": "星位被挂角后,先比较小飞应、夹攻、脱先三种方向。", + "correctIdea": "定式不是只有局部答案,要看旁边哪边更需要发展。", + "memoryCue": "被挂角先问:稳、攻、脱先,哪个更符合全局?", + "commonMistake": "机械小飞应,错过夹攻弱棋或抢大场的机会。", + "drill": "复盘前 30 手时,凡是挂角都列出三种候选:应、夹、脱先。" + } + }, + { + "id": "joseki_34_low_approach_slide", + "title": "小目低挂:滑守角", + "category": "joseki", + "patternType": "corner_joseki", + "phase": ["opening"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["corner"], + "tags": ["小目", "低挂", "滑", "守角", "定式", "实地"], + "aliases": ["小目滑", "3-4 low approach slide", "slide joseki"], + "boardSignals": ["corner", "3-4", "approach", "second-line"], + "triggers": { + "moveFeatures": ["3-4", "approach"], + "candidateFeatures": ["second-line", "corner"], + "pvFeatures": ["second-line", "corner"], + "contextTags": ["小目", "低挂", "定式"], + "minMoveNumber": 1, + "maxMoveNumber": 70 + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["3-4", "low-approach", "slide"], + "gtpExamples": ["Q16 R14 R17", "D16 C14 C17"], + "symmetry": "corner" + }, + "variations": [ + { + "name": "滑守角", + "mainLine": "守角方滑入角部,拿稳定实地,局面简明。", + "whenToChoose": "想要稳健收束,或者周围没有强烈攻击目标。", + "warning": "滑太早可能把先手和外势让给对方。" + }, + { + "name": "靠压/夹攻", + "mainLine": "更积极的选择是靠压或夹攻,把局部变成攻防转换。", + "whenToChoose": "外侧有己方配合,或对方挂角棋较薄。" + } + ], + "teaching": { + "recognition": "小目低挂后滑是最容易理解的实地型定式。", + "correctIdea": "它的优点是稳,缺点是可能缓;KataGo 若不选滑,多半全局有更积极方向。", + "memoryCue": "滑是拿角,靠压是取势,夹攻是攻击。", + "commonMistake": "只因滑熟悉就滑,没比较外侧是否有攻击价值。", + "drill": "把小目被挂角的局面分成三类:拿角、取势、攻击。" + } + }, + { + "id": "life_death_corner_carpenter_square", + "title": "角部曲四:劫活与死棋分界", + "category": "life_death", + "patternType": "corner_tsumego", + "phase": ["middlegame", "endgame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner"], + "tags": ["角部死活", "曲四", "劫活", "外气", "死活题"], + "aliases": ["carpenter square", "角部弯四", "曲四劫"], + "boardSignals": ["corner", "first-line", "second-line", "eye-shape", "ko"], + "triggers": { + "moveFeatures": ["corner", "second-line"], + "candidateFeatures": ["first-line", "second-line", "corner"], + "pvFeatures": ["first-line", "ko", "capture"], + "contextTags": ["死活", "角部", "劫", "计算"], + "minLossScore": 2.5, + "judgements": ["mistake", "blunder"] + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["corner-eye-space", "throw-in", "ko"], + "gtpExamples": ["A1 B1 A2 B2 C2"], + "symmetry": "corner" + }, + "variations": [ + { + "name": "外气足:劫活", + "mainLine": "攻方通常通过一线扑入制造劫,防守方需要靠劫材求活。", + "whenToChoose": "局部不能直接杀净,但全盘劫材足够。", + "warning": "不要只看局部活形,要先数外气和劫材。" + }, + { + "name": "外气不足:直接死", + "mainLine": "外围气紧时,曲四可能连劫都做不出来。", + "whenToChoose": "攻方外面厚,防守方气紧且没有反扑。" + } + ], + "teaching": { + "recognition": "角上弯成四目空间时,不能凭“弯四活”下结论。", + "correctIdea": "先数外气,再看一线扑入能不能成劫。", + "memoryCue": "普通弯四多活,角部曲四先想劫。", + "commonMistake": "把角部死活当边上死活,忽略棋盘边界让气变少。", + "drill": "做 10 道角部曲四题,每题先写外气数,再看答案。" + } + }, + { + "id": "life_death_corner_six_live_four_die", + "title": "角上六活四死:空间判断", + "category": "life_death", + "patternType": "corner_tsumego", + "phase": ["middlegame", "endgame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["corner"], + "tags": ["角部死活", "六活四死", "眼位", "空间", "死活题"], + "aliases": ["corner six live four die", "角上空间", "眼位空间"], + "boardSignals": ["corner", "eye-space", "first-line", "second-line"], + "triggers": { + "moveFeatures": ["corner"], + "candidateFeatures": ["first-line", "second-line", "corner"], + "pvFeatures": ["eye-shape", "capture"], + "contextTags": ["死活", "眼形", "角部"], + "minLossScore": 2, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["corner-eye-space", "vital-point"], + "gtpExamples": ["A1 A2 A3 B1 B2 C1"], + "symmetry": "corner" + }, + "variations": [ + { + "name": "空间足够", + "mainLine": "角部有足够眼位时,先占要点通常可以做出两眼。", + "whenToChoose": "防守方需要补活,且补一手能形成两个眼位。" + }, + { + "name": "空间不足", + "mainLine": "空间过小,攻方占中间要点后会变成假眼或劫。", + "whenToChoose": "攻方先手紧逼,防守方没有外气。" + } + ], + "teaching": { + "recognition": "角部死活先看空间大小,再看要点。", + "correctIdea": "KataGo 若在一二线推荐看似小的点,常是眼位急所。", + "memoryCue": "角上空间小,一线二线都是大棋。", + "commonMistake": "在外围补厚,却漏掉角里唯一做眼点。", + "drill": "做角部题时先圈出所有可能真眼,再看杀着。" + } + }, + { + "id": "life_death_bulky_five_vital_point", + "title": "刀把五:中间是急所", + "category": "life_death", + "patternType": "tsumego", + "phase": ["middlegame", "endgame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["corner", "side", "center"], + "tags": ["刀把五", "死活", "眼形", "急所", "死活题"], + "aliases": ["bulky five", "bulky-five vital point", "五目中手"], + "boardSignals": ["eye-shape", "vital-point", "capture"], + "triggers": { + "moveFeatures": ["eye-shape"], + "candidateFeatures": ["vital-point", "second-line", "corner"], + "pvFeatures": ["capture", "eye-shape"], + "contextTags": ["死活", "眼形", "急所", "计算"], + "minLossScore": 2, + "judgements": ["mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["bulky-five", "inside-vital-point"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "攻方先占中点", + "mainLine": "攻方下到眼形中心,防守方很难做出两个真眼。", + "whenToChoose": "对方眼位像一团五目空,且外气不够。" + }, + { + "name": "防守方先补要点", + "mainLine": "防守方先占中心要点,眼形会从假眼变成可活形。", + "whenToChoose": "这块棋是大龙,补活价值高于外面小官子。" + } + ], + "teaching": { + "recognition": "看到五目眼位不要平均看,先找中间的唯一要点。", + "correctIdea": "死活题不是把每个点都试一遍,而是找能同时破两个眼的点。", + "memoryCue": "刀把五,中间先。", + "commonMistake": "从边上收气,结果让对方补到眼形急所。", + "drill": "每天 5 道五目眼形题,只训练第一感找中点。" + } + }, + { + "id": "life_death_straight_three_bent_three", + "title": "直三弯三:先占中点", + "category": "life_death", + "patternType": "tsumego", + "phase": ["middlegame", "endgame"], + "levels": ["beginner", "intermediate"], + "regions": ["corner", "side", "center"], + "tags": ["直三", "弯三", "死活", "眼形", "急所", "死活题"], + "aliases": ["straight three", "bent three", "three-space eye"], + "boardSignals": ["eye-shape", "vital-point"], + "triggers": { + "moveFeatures": ["eye-shape"], + "candidateFeatures": ["vital-point", "second-line"], + "pvFeatures": ["eye-shape"], + "contextTags": ["死活", "眼形", "做活"], + "minLossScore": 1.5, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["straight-three", "middle-vital-point"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "攻方点中", + "mainLine": "攻方占中点,防守方很难分出两个真眼。", + "whenToChoose": "对方眼位只有三格,且外面无法接应。" + }, + { + "name": "防守方补中", + "mainLine": "防守方先补中点,眼形变厚,后续更容易活。", + "whenToChoose": "这块棋关系全局安全,补一手价值很大。" + } + ], + "teaching": { + "recognition": "直三、弯三第一眼找中点。", + "correctIdea": "中点是双方共同急所,谁先占谁改变死活结论。", + "memoryCue": "三格眼位,中点最大。", + "commonMistake": "从外面冲断,却把中点留给对方补活。", + "drill": "把直三、弯三、刀把五放在一起练,训练“共同急所”。" + } + }, + { + "id": "tesuji_snapback_throw_in", + "title": "倒扑:先送后吃", + "category": "tesuji", + "patternType": "capture_tesuji", + "phase": ["middlegame", "endgame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["corner", "side", "center"], + "tags": ["倒扑", "扑", "接不归", "手筋", "吃子", "死活题"], + "aliases": ["snapback", "throw-in", "先送后吃"], + "boardSignals": ["capture", "throw-in", "liberty-shortage"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["contact", "first-line", "second-line"], + "pvFeatures": ["capture", "contact"], + "contextTags": ["手筋", "吃子", "死活", "计算"], + "minLossScore": 2, + "judgements": ["mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["throw-in", "forced-capture", "recapture"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "扑入制造倒扑", + "mainLine": "先送一子,让对方吃掉后气变紧,再整体吃回。", + "whenToChoose": "对方形状有缺陷,吃你的子会变成自紧气。" + }, + { + "name": "接不归", + "mainLine": "通过扑和断,让对方表面能接,实际接完仍被吃。", + "whenToChoose": "对方连接点周围气少。" + } + ], + "teaching": { + "recognition": "看到自己一子可以送进去时,不要马上排除,先看对方吃完是否自紧气。", + "correctIdea": "倒扑的本质是用弃子改变对方气数。", + "memoryCue": "先送不是亏,送完能吃才是手筋。", + "commonMistake": "只按吃子数量判断,不看吃完后的气。", + "drill": "做倒扑题时,每题写出“对方吃完还剩几口气”。" + } + }, + { + "id": "tesuji_ladder_net_choice", + "title": "征子与枷:追杀前先看方向", + "category": "tesuji", + "patternType": "capture_tesuji", + "phase": ["middlegame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["side", "center", "corner"], + "tags": ["征子", "枷", "追杀", "逃跑", "手筋", "方向"], + "aliases": ["ladder", "net", "geta", "征子不利用枷"], + "boardSignals": ["running-group", "liberty-shortage", "direction"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["knight-move", "jump", "contact"], + "pvFeatures": ["running", "capture"], + "contextTags": ["手筋", "征子", "枷", "攻击", "方向"], + "minLossScore": 2, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["atari", "ladder", "net"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "征子成立", + "mainLine": "连续打吃把对方赶向没有接应的一边。", + "whenToChoose": "征子路线没有对方引征子。" + }, + { + "name": "征子不利改用枷", + "mainLine": "不用连续打吃,改用包围手限制对方逃跑。", + "whenToChoose": "征子方向有对方接应,直接追会崩。" + } + ], + "teaching": { + "recognition": "看到打吃前先沿斜线看终点,不要凭感觉追。", + "correctIdea": "征子是直线计算,枷是控制逃路;两者先比方向。", + "memoryCue": "征子先看路,路不好就用枷。", + "commonMistake": "征子不利还硬追,把自己棋形追薄。", + "drill": "每盘找一次打吃前的征子路线,画到棋盘边。" + } + }, + { + "id": "semeai_one_eye_beats_no_eye", + "title": "有眼杀无眼:对杀基本法", + "category": "life_death", + "patternType": "semeai", + "phase": ["middlegame", "endgame"], + "levels": ["beginner", "intermediate", "advanced", "dan"], + "regions": ["corner", "side", "center"], + "tags": ["对杀", "数气", "有眼杀无眼", "公气", "死活题"], + "aliases": ["one eye beats no eye", "semeai liberties"], + "boardSignals": ["semeai", "liberty-shortage", "eye-shape"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["contact", "vital-point"], + "pvFeatures": ["capture", "eye-shape"], + "contextTags": ["对杀", "数气", "死活", "战斗"], + "minLossScore": 2.5, + "judgements": ["mistake", "blunder"] + }, + "shape": { + "anchor": "local-fight", + "canonicalMoves": ["count-liberties", "make-eye", "take-liberty"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "有眼对无眼", + "mainLine": "有眼一方的眼内气通常不能被对方直接紧,公气计算会偏向有眼方。", + "whenToChoose": "双方互相包围且无法脱身。" + }, + { + "name": "先做眼再紧气", + "mainLine": "对杀中有时做眼比马上紧外气更大。", + "whenToChoose": "做眼能改变最终气数。" + } + ], + "teaching": { + "recognition": "对杀先分清:双方有没有眼,公气几口,外气几口。", + "correctIdea": "不要只数表面气;有眼和无眼的公气规则不同。", + "memoryCue": "对杀三问:有眼吗?公气几口?谁先紧谁?", + "commonMistake": "一上来就紧气,结果让对方先做眼。", + "drill": "做对杀题时固定写三行:我方外气、对方外气、公气。" + } + }, + { + "id": "shape_empty_triangle_bad_efficiency", + "title": "空三角:效率低的坏形", + "category": "shape", + "patternType": "shape_error", + "phase": ["opening", "middlegame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["corner", "side", "center"], + "tags": ["空三角", "愚形", "效率", "形状", "连接"], + "aliases": ["empty triangle", "bad shape", "低效连接"], + "boardSignals": ["bad-shape", "contact", "connection"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["jump", "knight-move", "connection"], + "pvFeatures": ["shape"], + "contextTags": ["形状", "效率", "愚形", "连接"], + "minLossScore": 1.5, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["empty-triangle", "better-extension"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "直接连接", + "mainLine": "用最少的子保持连接,避免三子挤在一起。", + "whenToChoose": "连接本身很重要,但不需要补得太厚。" + }, + { + "name": "跳出保持轻灵", + "mainLine": "用跳或飞保持发展,既照顾连接又有速度。", + "whenToChoose": "对方暂时切不断,棋需要向外发展。" + } + ], + "teaching": { + "recognition": "三颗子围成缺一角的三角,多半要警惕效率。", + "correctIdea": "坏形不是绝对不能下,而是要问有没有战术必要。", + "memoryCue": "能跳不挤,能飞不团。", + "commonMistake": "为了安全过度补棋,把轻棋补成重棋。", + "drill": "复盘时圈出所有空三角,标注它是必要补棋还是低效。" + } + }, + { + "id": "joseki_star_low_approach_one_space_pincer", + "title": "星位小飞挂:一间夹攻", + "category": "joseki", + "patternType": "corner_joseki", + "phase": ["opening"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner"], + "tags": ["星位", "小飞挂", "一间夹", "夹攻", "定式", "战斗"], + "aliases": ["one-space pincer", "hoshi pincer", "星位夹攻"], + "boardSignals": ["corner", "4-4", "approach", "pincer", "third-line"], + "triggers": { + "moveFeatures": ["approach", "corner"], + "candidateFeatures": ["third-line", "corner"], + "pvFeatures": ["corner", "running"], + "contextTags": ["星位", "夹攻", "挂角", "定式"], + "minMoveNumber": 1, + "maxMoveNumber": 70 + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["4-4", "low-approach", "one-space-pincer"], + "gtpExamples": ["Q16 R14 P14", "D16 C14 E14"], + "symmetry": "corner" + }, + "variations": [ + { + "name": "夹攻压迫", + "mainLine": "守角方不简单守角,而是夹住挂角一子,逼对方选择跳出、三三或靠压。", + "whenToChoose": "外侧有己方配合,或者对方挂角一子较孤立。", + "warning": "夹攻会主动制造战斗,自己周围薄时不要硬夹。" + }, + { + "name": "三三反击", + "mainLine": "被夹方常可点三三取角,把外势留给夹攻方。", + "whenToChoose": "夹攻方外面强,自己不想在外侧被追。" + } + ], + "teaching": { + "recognition": "星位被小飞挂后,靠近挂角子的第三线夹击点就是夹攻方向。", + "correctIdea": "夹攻不是为了马上吃子,而是让对方在低位取角和外面逃跑之间二选一。", + "memoryCue": "有配合才夹,没配合先稳。", + "commonMistake": "只因为想攻击就夹,结果自己的夹攻子反而变弱。", + "drill": "复盘每个夹攻前,先标出己方外侧有没有援军。" + } + }, + { + "id": "joseki_star_approach_tenuki", + "title": "星位被挂角:AI 脱先抢大场", + "category": "joseki", + "patternType": "opening_choice", + "phase": ["opening"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner", "side", "center"], + "tags": ["星位", "挂角", "脱先", "大场", "AI定式", "先手"], + "aliases": ["tenuki after approach", "AI tenuki", "被挂角脱先"], + "boardSignals": ["corner", "4-4", "approach", "global-direction"], + "triggers": { + "moveFeatures": ["approach", "corner"], + "candidateFeatures": ["center", "side", "corner"], + "pvFeatures": ["global"], + "contextTags": ["脱先", "大场", "先手", "方向"], + "minMoveNumber": 1, + "maxMoveNumber": 60 + }, + "shape": { + "anchor": "whole-board", + "canonicalMoves": ["4-4", "approach", "tenuki"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "脱先抢大场", + "mainLine": "暂时不应挂角,先抢全局价值更大的点。", + "whenToChoose": "角部暂时不会崩,另一边有急所、大场或攻击机会。", + "warning": "脱先后要知道对方继续压迫角部时自己能承受什么。" + }, + { + "name": "稳健小飞应", + "mainLine": "如果全局没有更大处,小飞应仍是最稳定的选择。", + "whenToChoose": "学生阶段不确定方向时,稳健应一手通常更容易掌控。" + } + ], + "teaching": { + "recognition": "被挂角并不等于必须应,AI 常把这个局部当作可暂缓问题。", + "correctIdea": "脱先是否成立,要比较角部后续损失和全局大场收益。", + "memoryCue": "不是不管角,是先问别处有没有更急。", + "commonMistake": "学 AI 脱先但不知道局部后续,被连续压迫后才补救。", + "drill": "前 40 手每次被挂角,写下“应、夹、脱先”三者各自理由。" + } + }, + { + "id": "joseki_34_low_approach_attach_extend", + "title": "小目低挂:靠压取势", + "category": "joseki", + "patternType": "corner_joseki", + "phase": ["opening"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner"], + "tags": ["小目", "低挂", "靠压", "外势", "定式", "方向"], + "aliases": ["komoku attach", "low approach attach", "attach and extend"], + "boardSignals": ["corner", "3-4", "approach", "contact", "influence"], + "triggers": { + "moveFeatures": ["3-4", "approach"], + "candidateFeatures": ["contact", "third-line", "corner"], + "pvFeatures": ["contact", "corner"], + "contextTags": ["小目", "靠压", "低挂", "定式"], + "minMoveNumber": 1, + "maxMoveNumber": 80 + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["3-4", "low-approach", "attach", "extend"], + "gtpExamples": ["Q16 R14 Q14", "D16 C14 D14"], + "symmetry": "corner" + }, + "variations": [ + { + "name": "靠压取势", + "mainLine": "守角方靠住挂角子,通过压迫取得外势或先手。", + "whenToChoose": "外侧有发展价值,或需要限制对方轻松拆边。", + "warning": "靠压通常会给对方安定机会,不能只看局部舒服。" + }, + { + "name": "滑守角", + "mainLine": "选择滑时更重视角部实地,变化简单。", + "whenToChoose": "外侧价值不大,或自己想降低复杂度。" + } + ], + "teaching": { + "recognition": "小目低挂后,靠压和滑是两种完全不同的价值选择。", + "correctIdea": "靠压要看外势有没有用,滑要看会不会太缓。", + "memoryCue": "滑拿角,靠压拿势。", + "commonMistake": "靠压后没有外部目标,结果只是帮对方安定。", + "drill": "把小目低挂局面分成“我要角”或“我要势”两类。" + } + }, + { + "id": "joseki_34_high_approach_one_space_low_pincer", + "title": "小目高挂:一间低夹", + "category": "joseki", + "patternType": "corner_joseki", + "phase": ["opening"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner"], + "tags": ["小目", "高挂", "一间低夹", "夹攻", "定式"], + "aliases": ["high approach low pincer", "komoku pincer", "一间低夹"], + "boardSignals": ["corner", "3-4", "approach", "pincer", "third-line"], + "triggers": { + "moveFeatures": ["3-4", "approach"], + "candidateFeatures": ["third-line", "corner"], + "pvFeatures": ["running", "corner"], + "contextTags": ["高挂", "小目", "夹攻", "定式"], + "minMoveNumber": 1, + "maxMoveNumber": 80 + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["3-4", "high-approach", "low-pincer"], + "gtpExamples": ["Q16 O17 R14", "D16 E17 C14"], + "symmetry": "corner" + }, + "variations": [ + { + "name": "一间低夹", + "mainLine": "低位夹攻强调限制对方根基,逼高挂子向中央处理。", + "whenToChoose": "想攻击挂角子,同时保留角部实地。", + "warning": "夹攻后自己也要准备战斗,不能夹完就脱先。" + }, + { + "name": "简明应对", + "mainLine": "直接应住角部,放弃主动攻击,换来稳定。", + "whenToChoose": "自己外侧没有支援,或局面不适合开战。" + } + ], + "teaching": { + "recognition": "高挂比低挂更轻,夹攻时更容易形成中央战斗。", + "correctIdea": "一间低夹的核心是压低对方根基,而不是马上吃掉。", + "memoryCue": "高挂轻,夹攻要看后续追击。", + "commonMistake": "低夹后没有连续攻击计划,让对方轻松跑出。", + "drill": "看到高挂夹攻,先想对方跳出后我下一手在哪里追。" + } + }, + { + "id": "joseki_star_double_wing_splitting_invasion", + "title": "星位双翼展开:分投与打入", + "category": "joseki", + "patternType": "opening_invasion", + "phase": ["opening", "middlegame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["side", "center"], + "tags": ["星位", "双翼", "分投", "打入", "侵消", "模样"], + "aliases": ["double wing formation", "splitting invasion", "framework invasion"], + "boardSignals": ["side", "framework", "invasion", "third-line"], + "triggers": { + "moveFeatures": ["side", "third-line"], + "candidateFeatures": ["side", "third-line"], + "pvFeatures": ["running", "invasion"], + "contextTags": ["模样", "打入", "侵消", "分投"], + "minMoveNumber": 20, + "maxMoveNumber": 120 + }, + "shape": { + "anchor": "side-framework", + "canonicalMoves": ["double-wing", "splitting-invasion", "settle-lightly"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "三线分投", + "mainLine": "在对方框架中间轻轻落子,目标是就地安定或腾挪出头。", + "whenToChoose": "对方模样开始成形,但还没有完全封锁。" + }, + { + "name": "浅消", + "mainLine": "不深入打入,只在外侧压缩对方发展。", + "whenToChoose": "自己薄,深入后可能被攻击。" + } + ], + "teaching": { + "recognition": "对方星位双翼展开时,中间三线常有分投点。", + "correctIdea": "分投不是为了吃地,而是让对方模样无法自然成空。", + "memoryCue": "模样未封先分投,已厚就浅消。", + "commonMistake": "太晚才进,进来后只能逃命。", + "drill": "复盘每个大模样,标出最早可以分投的一手。" + } + }, + { + "id": "life_death_l_group_basic", + "title": "角部 L 形:基础死活判断", + "category": "life_death", + "patternType": "corner_tsumego", + "phase": ["middlegame", "endgame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner"], + "tags": ["角部死活", "L形", "眼位", "一线", "二线", "死活题"], + "aliases": ["L group", "corner L shape", "L形死活"], + "boardSignals": ["corner", "first-line", "second-line", "eye-shape"], + "triggers": { + "moveFeatures": ["corner", "second-line"], + "candidateFeatures": ["first-line", "second-line", "corner"], + "pvFeatures": ["eye-shape", "capture"], + "contextTags": ["死活", "角部", "眼形", "计算"], + "minLossScore": 2, + "judgements": ["mistake", "blunder"] + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["l-group", "inside-vital-point", "liberty-count"], + "gtpExamples": [], + "symmetry": "corner" + }, + "variations": [ + { + "name": "外气足", + "mainLine": "外气充足时,防守方可能通过内部要点做活或成劫。", + "whenToChoose": "外围不紧,攻方不能直接收气。" + }, + { + "name": "外气紧", + "mainLine": "外气不足时,攻方一线或二线要点会直接破眼。", + "whenToChoose": "防守方不能脱先,必须先补眼位。" + } + ], + "teaching": { + "recognition": "角部 L 形不要凭形状名判断,先数外气再找内部要点。", + "correctIdea": "角部边界会减少变化空间,很多“看似有眼”的形状其实只是劫或死。", + "memoryCue": "L 形先数气,再点眼。", + "commonMistake": "只看空有多大,不看外气是否允许做活。", + "drill": "每道角部 L 形题先写外气数,再看第一手。" + } + }, + { + "id": "life_death_corner_throw_in_1_2_point", + "title": "角部一二路扑入:破眼急所", + "category": "life_death", + "patternType": "corner_tsumego", + "phase": ["middlegame", "endgame"], + "levels": ["beginner", "intermediate", "advanced", "dan"], + "regions": ["corner"], + "tags": ["角部死活", "扑", "一二路", "破眼", "倒扑", "死活题"], + "aliases": ["1-2 point throw-in", "corner throw-in", "角上一二路"], + "boardSignals": ["corner", "first-line", "second-line", "throw-in", "eye-shape"], + "triggers": { + "moveFeatures": ["corner", "second-line"], + "candidateFeatures": ["first-line", "second-line", "corner"], + "pvFeatures": ["capture", "eye-shape"], + "contextTags": ["死活", "扑", "角部", "破眼"], + "minLossScore": 2, + "judgements": ["mistake", "blunder"] + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["throw-in", "false-eye", "capture"], + "gtpExamples": ["A2", "B1", "S2", "R1"], + "symmetry": "corner" + }, + "variations": [ + { + "name": "扑入破眼", + "mainLine": "攻方在一二路送一子,迫使对方吃后形成假眼或倒扑。", + "whenToChoose": "对方角部眼位紧凑,吃子后会自紧气。" + }, + { + "name": "防守补要点", + "mainLine": "防守方抢先补住一二路要点,避免眼形被破。", + "whenToChoose": "这块棋关系全局安全,不能让对方先手破眼。" + } + ], + "teaching": { + "recognition": "角上一二路常出现看似送死、实际破眼的扑入。", + "correctIdea": "送进去以后要看对方吃完是不是少一口气、假一个眼。", + "memoryCue": "角上小点不小,一二路常是命门。", + "commonMistake": "怕送子,错过唯一杀棋。", + "drill": "做角部死活题时,先检查一线、二线有没有扑入点。" + } + }, + { + "id": "life_death_side_second_line_hane", + "title": "边上二路扳:缩眼空间", + "category": "life_death", + "patternType": "side_tsumego", + "phase": ["middlegame", "endgame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["side"], + "tags": ["边上死活", "二路扳", "缩眼", "做活", "死活题"], + "aliases": ["second-line hane", "side hane", "边上扳"], + "boardSignals": ["side", "second-line", "eye-shape", "contact"], + "triggers": { + "moveFeatures": ["side", "second-line"], + "candidateFeatures": ["second-line", "contact"], + "pvFeatures": ["eye-shape", "capture"], + "contextTags": ["死活", "边上", "做活", "扳"], + "minLossScore": 1.5, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "side-shape", + "canonicalMoves": ["second-line-hane", "eye-space-reduction"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "扳缩眼", + "mainLine": "攻方二路扳压缩眼位,让防守方无法分出两个真眼。", + "whenToChoose": "对方沿边做活,眼位空间还没定型。" + }, + { + "name": "先手补眼", + "mainLine": "防守方先在二路补要点,保留两眼空间。", + "whenToChoose": "边上大龙未活,外面也没有接应。" + } + ], + "teaching": { + "recognition": "边上做活时,二路线扳常常比外面追击更急。", + "correctIdea": "死活的关键是眼位空间,不是外面多围一点。", + "memoryCue": "边上二路扳,先问眼够不够。", + "commonMistake": "只从外面封锁,放过里面做活要点。", + "drill": "边上死活题先画出两个可能眼位,再找扳点。" + } + }, + { + "id": "life_death_seki_false_capture", + "title": "双活:不能随便吃", + "category": "life_death", + "patternType": "seki", + "phase": ["middlegame", "endgame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner", "side", "center"], + "tags": ["双活", "公气", "假吃", "死活", "收官"], + "aliases": ["seki", "mutual life", "shared liberties"], + "boardSignals": ["semeai", "shared-liberty", "eye-shape"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["vital-point", "contact"], + "pvFeatures": ["capture", "eye-shape"], + "contextTags": ["双活", "公气", "死活", "对杀"], + "minLossScore": 2, + "judgements": ["mistake", "blunder"] + }, + "shape": { + "anchor": "local-fight", + "canonicalMoves": ["shared-liberty", "do-not-capture", "seki"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "维持双活", + "mainLine": "双方都不能先吃,先吃反而让对方形成真眼或反杀。", + "whenToChoose": "局部双方共享公气,且谁先动谁亏。" + }, + { + "name": "破双活", + "mainLine": "当外气或劫材变化后,原本双活可能被破坏。", + "whenToChoose": "全局变化让一方多出紧气或打劫手段。" + } + ], + "teaching": { + "recognition": "双活局面常有诱人的吃子点,但那可能是陷阱。", + "correctIdea": "先判断谁先动会不会减少自己的气或给对方成眼。", + "memoryCue": "双活不是没棋,是先动可能亏。", + "commonMistake": "看到能吃就吃,结果把双活变成自己死。", + "drill": "复盘所有双活,标出哪些点双方都不能下。" + } + }, + { + "id": "tesuji_shortage_of_liberties", + "title": "紧气手筋:让对方自己缺气", + "category": "tesuji", + "patternType": "capture_tesuji", + "phase": ["middlegame", "endgame"], + "levels": ["beginner", "intermediate", "advanced", "dan"], + "regions": ["corner", "side", "center"], + "tags": ["紧气", "气紧", "手筋", "吃子", "对杀"], + "aliases": ["shortage of liberties", "liberty shortage", "气合い"], + "boardSignals": ["liberty-shortage", "contact", "capture"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["contact", "vital-point"], + "pvFeatures": ["capture", "contact"], + "contextTags": ["紧气", "手筋", "吃子", "对杀"], + "minLossScore": 2, + "judgements": ["mistake", "blunder"] + }, + "shape": { + "anchor": "local-fight", + "canonicalMoves": ["force", "self-atari-risk", "capture"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "逼应紧气", + "mainLine": "先走 forcing move,让对方不得不应,随后气数不足。", + "whenToChoose": "对方几块棋互相牵连,补一边会亏另一边。" + }, + { + "name": "弃子紧气", + "mainLine": "送一子进去迫使对方吃,吃完反而气紧。", + "whenToChoose": "送子后能形成倒扑或接不归。" + } + ], + "teaching": { + "recognition": "局部接触战中,先数气,再看有没有逼对方自紧的手。", + "correctIdea": "紧气手筋的价值不在这一手吃多少,而在让后续吃子变成必然。", + "memoryCue": "先逼它应,再看它气。", + "commonMistake": "直接打吃太早,让对方轻松补气。", + "drill": "每个吃子题先数双方气,再找 forcing move。" + } + }, + { + "id": "tesuji_clamp_under_connection", + "title": "夹:压缩眼位与连接", + "category": "tesuji", + "patternType": "shape_tesuji", + "phase": ["middlegame", "endgame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["side", "corner"], + "tags": ["夹", "二路", "连接", "破眼", "手筋", "腾挪"], + "aliases": ["clamp", "hasami-tsuke", "under the stones"], + "boardSignals": ["side", "second-line", "contact", "connection"], + "triggers": { + "moveFeatures": ["side", "second-line"], + "candidateFeatures": ["contact", "second-line"], + "pvFeatures": ["connection", "eye-shape"], + "contextTags": ["手筋", "连接", "夹", "腾挪"], + "minLossScore": 2, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "side-shape", + "canonicalMoves": ["clamp", "connect-under", "squeeze"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "二路夹", + "mainLine": "从下方夹住对方形状,压缩眼位或制造连接手段。", + "whenToChoose": "边上双方接触,二路还有潜在连接。" + }, + { + "name": "弃子转换", + "mainLine": "夹不一定要救出所有子,常用来换取先手或外围厚味。", + "whenToChoose": "局部小子可以弃,外面收益更大。" + } + ], + "teaching": { + "recognition": "边上二路接触时,夹常常比直接长更有弹性。", + "correctIdea": "夹的目标是同时威胁连接、破眼和收气。", + "memoryCue": "二路有夹,先看下方通不通。", + "commonMistake": "只会直接连接,错过二路制造手段。", + "drill": "边上手筋题先找夹,再找直接长或接。" + } + }, + { + "id": "tesuji_peep_before_cut", + "title": "先窥再断:保留强制应手", + "category": "tesuji", + "patternType": "probe_tesuji", + "phase": ["opening", "middlegame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner", "side", "center"], + "tags": ["窥", "试应手", "断", "手筋", "味"], + "aliases": ["peep before cut", "probe", "forcing peep"], + "boardSignals": ["connection", "cut", "probe"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["contact", "connection"], + "pvFeatures": ["cut", "shape"], + "contextTags": ["窥", "试探", "断", "味"], + "minLossScore": 1.5, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["peep", "answer", "cut-or-connect"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "先窥定型", + "mainLine": "先逼对方补连接,再根据对方形状选择断或转身。", + "whenToChoose": "对方连接点薄,且窥是先手。" + }, + { + "name": "保留味道", + "mainLine": "不急着窥,保留将来利用的可能。", + "whenToChoose": "现在窥会帮对方补强,后续反而少了味。" + } + ], + "teaching": { + "recognition": "窥是让对方表态,但不是所有窥都要马上走。", + "correctIdea": "好窥是先手且让后续选择更清楚;坏窥是帮对方补强。", + "memoryCue": "窥之前问:它是不是先手?会不会帮对方变厚?", + "commonMistake": "习惯性先窥,把自己的味消掉。", + "drill": "复盘所有窥,标记它是好交换还是味消。" + } + }, + { + "id": "tesuji_throw_in_for_ko", + "title": "扑入造劫:把死棋变成劫争", + "category": "tesuji", + "patternType": "ko_tesuji", + "phase": ["middlegame", "endgame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner", "side"], + "tags": ["扑", "造劫", "劫", "死活", "手筋"], + "aliases": ["throw-in for ko", "ko tesuji", "劫活"], + "boardSignals": ["throw-in", "ko", "first-line", "second-line"], + "triggers": { + "moveFeatures": ["corner", "second-line"], + "candidateFeatures": ["first-line", "second-line", "contact"], + "pvFeatures": ["capture", "ko"], + "contextTags": ["劫", "扑", "死活", "角部"], + "minLossScore": 2.5, + "judgements": ["mistake", "blunder"] + }, + "shape": { + "anchor": "corner-or-side", + "canonicalMoves": ["throw-in", "capture", "ko"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "造劫求活", + "mainLine": "防守方通过扑入制造劫,给本来可能死的棋留下争劫机会。", + "whenToChoose": "全盘有足够劫材,直接做活不成立。" + }, + { + "name": "消劫杀棋", + "mainLine": "攻方判断劫材优势后,可以选择把局部变成劫争。", + "whenToChoose": "自己劫材多,局部价值足够大。" + } + ], + "teaching": { + "recognition": "死活题中如果直活不行,要看能不能扑入造劫。", + "correctIdea": "劫不是局部答案,必须和全盘劫材一起判断。", + "memoryCue": "活不了先想劫,打不赢劫就别开。", + "commonMistake": "只看到局部能成劫,却没数全盘劫材。", + "drill": "每个劫活题写两列:我的劫材、对方劫材。" + } + }, + { + "id": "shape_tiger_mouth_connection", + "title": "虎口连接:厚而不笨", + "category": "shape", + "patternType": "good_shape", + "phase": ["opening", "middlegame"], + "levels": ["beginner", "intermediate"], + "regions": ["corner", "side", "center"], + "tags": ["虎口", "连接", "好形", "厚薄", "形状"], + "aliases": ["tiger mouth", "tiger connection", "虎"], + "boardSignals": ["connection", "good-shape", "contact"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["connection", "knight-move"], + "pvFeatures": ["shape"], + "contextTags": ["连接", "虎口", "好形", "厚薄"], + "minLossScore": 1, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["tiger-mouth", "connection", "eye-potential"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "虎口连接", + "mainLine": "用虎口保持连接,同时留下眼形和反击弹性。", + "whenToChoose": "两边棋都重要,不能被切断。" + }, + { + "name": "轻灵跳出", + "mainLine": "如果对方暂时切不断,跳出比虎口更有速度。", + "whenToChoose": "当前目标是发展和出头,不是局部补厚。" + } + ], + "teaching": { + "recognition": "虎口是既连接又有眼形潜力的基本好形。", + "correctIdea": "好形也要看时机:该快时不要补得过厚,该稳时虎口很可靠。", + "memoryCue": "怕断用虎,不怕断就跳。", + "commonMistake": "该跳出时补虎,效率变低。", + "drill": "复盘所有连接手,比较虎口、粘、跳三种选择。" + } + }, + { + "id": "shape_bamboo_joint_connection", + "title": "竹节:难以切断的好形", + "category": "shape", + "patternType": "good_shape", + "phase": ["opening", "middlegame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["side", "center", "corner"], + "tags": ["竹节", "连接", "好形", "切断", "形状"], + "aliases": ["bamboo joint", "bamboo connection", "竹节连接"], + "boardSignals": ["connection", "good-shape", "cut"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["connection", "jump"], + "pvFeatures": ["shape", "connection"], + "contextTags": ["竹节", "连接", "好形", "切断"], + "minLossScore": 1, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["bamboo-joint", "anti-cut", "shape-efficiency"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "竹节防断", + "mainLine": "两组棋形成竹节,对方即使冲断也难以真正切开。", + "whenToChoose": "需要兼顾连接和效率,不想粘得太笨。" + }, + { + "name": "直接粘", + "mainLine": "局部气紧或有战术风险时,直接粘更安全。", + "whenToChoose": "竹节有被打吃或利用的缺陷。" + } + ], + "teaching": { + "recognition": "竹节看起来有空隙,但通常是很强的连接形。", + "correctIdea": "竹节的价值是省一手粘,同时保留形状弹性。", + "memoryCue": "竹节有空,但不容易断。", + "commonMistake": "看见空隙就补粘,把好形补成低效。", + "drill": "看到连接问题时,先找有没有竹节形。" + } + }, + { + "id": "shape_hane_at_head_two_stones", + "title": "二子头必扳:压迫形状急所", + "category": "shape", + "patternType": "shape_tesuji", + "phase": ["opening", "middlegame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["corner", "side", "center"], + "tags": ["二子头必扳", "扳", "压迫", "形状", "手筋"], + "aliases": ["hane at the head of two stones", "head of two stones", "二子头"], + "boardSignals": ["contact", "hane", "shape"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["contact", "vital-point"], + "pvFeatures": ["shape", "running"], + "contextTags": ["扳", "形状", "二子头", "攻击"], + "minLossScore": 1, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["two-stones", "hane-at-head", "shape-pressure"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "扳头压迫", + "mainLine": "在对方两子头部扳,压低对方形状并争取先手。", + "whenToChoose": "对方两子还未安定,扳后自己不薄。" + }, + { + "name": "不扳保留", + "mainLine": "如果扳会把自己走重,暂时保留更好。", + "whenToChoose": "全局更大,或局部扳后对方反击严厉。" + } + ], + "teaching": { + "recognition": "对方两子并排向前时,头部扳常是形状急所。", + "correctIdea": "这条格言不是绝对命令,而是提醒你先检查压迫点。", + "memoryCue": "二子头先看扳,扳完别把自己变薄。", + "commonMistake": "机械套格言,不看周围厚薄。", + "drill": "每次看到二子头,写下扳的收益和风险各一条。" + } + }, + { + "id": "life_death_true_eye_false_eye", + "title": "真眼与假眼:活棋判断第一课", + "category": "life_death", + "patternType": "eye_shape", + "phase": ["middlegame", "endgame"], + "levels": ["beginner", "intermediate"], + "regions": ["corner", "side", "center"], + "tags": ["真眼", "假眼", "两眼活", "死活", "眼形"], + "aliases": ["true eye", "false eye", "two eyes", "eye status"], + "boardSignals": ["eye-shape", "connection", "capture"], + "triggers": { + "moveFeatures": ["eye-shape"], + "candidateFeatures": ["vital-point", "connection"], + "pvFeatures": ["eye-shape", "capture"], + "contextTags": ["死活", "眼形", "真眼", "假眼"], + "minLossScore": 1, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["true-eye", "false-eye", "connection-defect"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "真眼", + "mainLine": "对方不能直接下进去,或者下进去会被立即提掉。", + "whenToChoose": "判断己方大龙是否已经安定。" + }, + { + "name": "假眼", + "mainLine": "表面像眼,但因为连接缺陷或气紧,对方可以破掉。", + "whenToChoose": "攻击对方薄棋时,先找假眼来源。" + } + ], + "teaching": { + "recognition": "看到两个空点不要马上说活,先判断它们是不是真眼。", + "correctIdea": "死活的底层逻辑是对方能不能合法破眼,以及破眼后是否会被提。", + "memoryCue": "两眼活,假眼不算眼。", + "commonMistake": "只数空点数量,不看连接缺陷。", + "drill": "做死活题时,把每个眼位标成真眼、假眼或未定眼。" + } + }, + { + "id": "life_death_rectangular_six_alive", + "title": "方六与长六:常见活形边界", + "category": "life_death", + "patternType": "eye_shape", + "phase": ["middlegame", "endgame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["corner", "side", "center"], + "tags": ["方六", "长六", "活形", "眼位", "死活题"], + "aliases": ["rectangular six", "six-space eye", "living shape"], + "boardSignals": ["eye-shape", "vital-point"], + "triggers": { + "moveFeatures": ["eye-shape"], + "candidateFeatures": ["vital-point", "second-line"], + "pvFeatures": ["eye-shape"], + "contextTags": ["死活", "眼形", "活形"], + "minLossScore": 1, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["rectangular-six", "straight-six", "inside-vital-point"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "空间足够", + "mainLine": "六目空间通常有较强的做活潜力,但仍需看外气和形状。", + "whenToChoose": "防守方判断是否可以脱先。" + }, + { + "name": "形状缺陷", + "mainLine": "即使空间看似够,如果连接坏或气紧,仍可能被点杀或成劫。", + "whenToChoose": "攻方先检查中点和切断。" + } + ], + "teaching": { + "recognition": "方六、长六常是活形边界题,不能只背“六活”。", + "correctIdea": "空间大小、外气、先后手一起决定死活。", + "memoryCue": "六目有希望,气紧仍要算。", + "commonMistake": "看到六目就脱先,漏掉对方先手点眼。", + "drill": "把六目眼形按角、边、中腹各做 3 道,比较差异。" + } + }, + { + "id": "life_death_under_the_stones", + "title": "盘角曲折:倒脱靴与石下手筋", + "category": "life_death", + "patternType": "under_the_stones", + "phase": ["middlegame", "endgame"], + "levels": ["advanced", "dan"], + "regions": ["corner", "side"], + "tags": ["石下", "倒脱靴", "扑", "死活", "手筋"], + "aliases": ["under the stones", "ishi no shita", "hidden tesuji"], + "boardSignals": ["capture", "throw-in", "eye-shape", "ko"], + "triggers": { + "moveFeatures": ["corner", "second-line"], + "candidateFeatures": ["first-line", "contact"], + "pvFeatures": ["capture", "eye-shape"], + "contextTags": ["石下", "倒脱靴", "死活", "手筋"], + "minLossScore": 2.5, + "judgements": ["mistake", "blunder"] + }, + "shape": { + "anchor": "corner-or-side", + "canonicalMoves": ["throw-in", "capture", "under-the-stones"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "先送后现", + "mainLine": "先让对方提掉一些子,提子后的空点反而出现新的要点。", + "whenToChoose": "局部有连续打吃或扑入后重生的可能。" + }, + { + "name": "成劫", + "mainLine": "石下手筋常不能直接活杀,而是转成劫争。", + "whenToChoose": "全盘劫材足够,局部价值很大。" + } + ], + "teaching": { + "recognition": "当直接做活/杀棋都不成立时,要看提子后棋盘会变成什么样。", + "correctIdea": "高级死活不是当前棋形一眼判断,而是读到几手后的新棋形。", + "memoryCue": "石下手筋,先想提完以后。", + "commonMistake": "看到自己的子会被提就停止计算。", + "drill": "每周做 5 道石下题,只训练“被提以后再看一眼”。" + } + }, + { + "id": "life_death_ko_threat_counting", + "title": "劫活:劫材数量决定结论", + "category": "life_death", + "patternType": "ko_life", + "phase": ["middlegame", "endgame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner", "side", "center"], + "tags": ["劫活", "劫材", "死活", "打劫", "价值判断"], + "aliases": ["ko life", "ko threats", "life by ko"], + "boardSignals": ["ko", "eye-shape", "capture"], + "triggers": { + "moveFeatures": ["eye-shape"], + "candidateFeatures": ["first-line", "second-line", "vital-point"], + "pvFeatures": ["ko", "capture"], + "contextTags": ["劫", "劫材", "死活", "价值判断"], + "minLossScore": 2, + "judgements": ["mistake", "blunder"] + }, + "shape": { + "anchor": "local-fight", + "canonicalMoves": ["ko", "ko-threats", "resolve"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "劫材足", + "mainLine": "防守方可以把局部变成劫,靠全盘劫材争活。", + "whenToChoose": "自己劫材多,且局部价值足够。" + }, + { + "name": "劫材不足", + "mainLine": "如果劫材不够,局部即使能成劫也不等于能活。", + "whenToChoose": "对方劫材更多,或劫价值远大于自己的威胁。" + } + ], + "teaching": { + "recognition": "KataGo 推荐成劫时,不要只看局部,要马上数全盘劫材。", + "correctIdea": "劫活的答案是局部手段加全局劫材共同决定的。", + "memoryCue": "能成劫是一半,能打赢才是答案。", + "commonMistake": "把“成劫”误当成“已经活”。", + "drill": "每次复盘劫争,记录双方前三个最大劫材。" + } + }, + { + "id": "tesuji_squeeze_shape_profit", + "title": "滚打包收:弃子换厚味", + "category": "tesuji", + "patternType": "squeeze_tesuji", + "phase": ["middlegame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner", "side", "center"], + "tags": ["滚打包收", "挤压", "弃子", "厚味", "手筋"], + "aliases": ["squeeze", "sacrifice squeeze", "shibori"], + "boardSignals": ["capture", "sacrifice", "shape", "contact"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["contact", "vital-point"], + "pvFeatures": ["capture", "shape"], + "contextTags": ["弃子", "手筋", "厚味", "挤压"], + "minLossScore": 2, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-fight", + "canonicalMoves": ["sacrifice", "force", "squeeze", "take-outside"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "弃子挤压", + "mainLine": "主动舍弃小棋,迫使对方一路提吃,自己在外面形成厚味或先手。", + "whenToChoose": "被弃的子价值小,外部厚味能攻击或围空。" + }, + { + "name": "避免被挤", + "mainLine": "对方想滚打包收时,不一定要顺着吃,先看有没有反方向的轻处理。", + "whenToChoose": "吃子会让自己过度集中或被封锁。" + } + ], + "teaching": { + "recognition": "如果一串被吃的小子能让外面变厚,要考虑滚打包收。", + "correctIdea": "手筋的目标不是救每颗子,而是让弃子变成外部收益。", + "memoryCue": "小子能弃,外面要厚。", + "commonMistake": "舍不得弃子,结果整块棋变重。", + "drill": "复盘时找一个可以弃的小棋,写下弃掉后外面得到什么。" + } + }, + { + "id": "tesuji_wedge_split_connection", + "title": "挖:切断与连接的交叉点", + "category": "tesuji", + "patternType": "cutting_tesuji", + "phase": ["middlegame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner", "side", "center"], + "tags": ["挖", "切断", "连接", "手筋", "接触战"], + "aliases": ["wedge", "cutting wedge", "warikomi"], + "boardSignals": ["cut", "connection", "contact"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["contact", "vital-point"], + "pvFeatures": ["cut", "connection"], + "contextTags": ["挖", "切断", "连接", "手筋"], + "minLossScore": 2, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["wedge", "atari-choice", "cut-or-connect"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "挖断", + "mainLine": "在对方两个连接点中间挖入,逼对方选择一边。", + "whenToChoose": "对方两边不能兼顾,挖后至少一边会出问题。" + }, + { + "name": "反挖后补", + "mainLine": "被挖时不要慌,先看哪边更重要,必要时弃小保大。", + "whenToChoose": "两边价值不同,不能平均应。" + } + ], + "teaching": { + "recognition": "挖通常出现在对方两块棋想同时连接的薄点。", + "correctIdea": "挖的价值来自让对方不能两边都要。", + "memoryCue": "两边都想连,中间可能挖。", + "commonMistake": "看到挖就本能打吃,没比较哪边更大。", + "drill": "接触战里先找双方连接点,再看是否有挖。" + } + }, + { + "id": "tesuji_crosscut_reading_order", + "title": "扭十字:先数气再选打吃", + "category": "tesuji", + "patternType": "contact_fight", + "phase": ["opening", "middlegame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["corner", "side", "center"], + "tags": ["扭十字", "打吃方向", "接触战", "数气", "手筋"], + "aliases": ["crosscut", "cross-cut fight", "contact fight"], + "boardSignals": ["contact", "cut", "liberty-shortage"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["contact", "vital-point"], + "pvFeatures": ["capture", "running"], + "contextTags": ["扭十字", "接触战", "打吃", "数气"], + "minLossScore": 1.5, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-fight", + "canonicalMoves": ["crosscut", "atari-direction", "liberty-count"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "正确打吃方向", + "mainLine": "扭十字后打吃方向决定双方形状,不能凭习惯。", + "whenToChoose": "接触战刚开始,双方都还薄。" + }, + { + "name": "先长气", + "mainLine": "有时不急着打吃,先长气能让后续战斗更有利。", + "whenToChoose": "自己气短,打吃会被反打。" + } + ], + "teaching": { + "recognition": "扭十字出现时,局部会迅速变成气数和方向问题。", + "correctIdea": "先数气、再看哪边不能被切,最后才决定打吃方向。", + "memoryCue": "扭十字别急打,先数气。", + "commonMistake": "见切就打吃,结果自己气紧。", + "drill": "每个扭十字题先写双方气数,再读两种打吃方向。" + } + }, + { + "id": "tesuji_monkey_jump_endgame", + "title": "猴子翻跟斗:边上大官子", + "category": "tesuji", + "patternType": "endgame_tesuji", + "phase": ["endgame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["side"], + "tags": ["猴子翻跟斗", "官子", "边上", "侵入", "先手"], + "aliases": ["monkey jump", "endgame invasion", "edge jump"], + "boardSignals": ["side", "second-line", "endgame"], + "triggers": { + "moveFeatures": ["side", "second-line"], + "candidateFeatures": ["side", "second-line"], + "pvFeatures": ["endgame", "territory"], + "contextTags": ["官子", "边上", "猴子", "先手"], + "minMoveNumber": 120, + "minLossScore": 1, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "side-endgame", + "canonicalMoves": ["monkey-jump", "edge-reduction", "sente-endgame"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "二路跳入", + "mainLine": "从边上二路跳入,压缩对方边空并争取先手。", + "whenToChoose": "对方边空尚有缺口,自己可以安全撤出。" + }, + { + "name": "稳健挡住", + "mainLine": "防守方选择正确方向挡住,避免被连续搜刮。", + "whenToChoose": "边空价值很大,不能让对方先手侵入。" + } + ], + "teaching": { + "recognition": "官子阶段边上二路常有超出直觉的大官子。", + "correctIdea": "猴子跳不是花招,而是利用边界压缩对方眼位和实地。", + "memoryCue": "官子看边,二路有猴。", + "commonMistake": "以为边上只剩小官子,漏掉连续侵入。", + "drill": "每盘官子阶段先检查四条边有没有二路跳入。" + } + }, + { + "id": "shape_diagonal_connection_vs_solid", + "title": "尖与粘:连接效率选择", + "category": "shape", + "patternType": "connection_choice", + "phase": ["opening", "middlegame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["corner", "side", "center"], + "tags": ["尖", "粘", "连接", "效率", "形状"], + "aliases": ["diagonal connection", "solid connection", "kosumi"], + "boardSignals": ["connection", "shape", "contact"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["connection", "knight-move"], + "pvFeatures": ["shape", "connection"], + "contextTags": ["连接", "形状", "效率", "尖"], + "minLossScore": 1, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["diagonal-connection", "solid-connection", "cut-risk"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "尖连接", + "mainLine": "用斜向连接保留速度和弹性,但可能留下切断味。", + "whenToChoose": "对方暂时切不断,自己需要发展。" + }, + { + "name": "实粘", + "mainLine": "直接粘住最安全,但效率较低。", + "whenToChoose": "局部气紧或被切断会很严重。" + } + ], + "teaching": { + "recognition": "连接不是只有粘,尖、虎、竹节都可能更高效。", + "correctIdea": "连接手要在安全和速度之间选平衡。", + "memoryCue": "危险就粘,安全就尖。", + "commonMistake": "所有连接都实粘,棋形变慢。", + "drill": "每次连接前比较:粘、尖、虎,哪一个刚好够安全。" + } + }, + { + "id": "shape_one_space_jump_light_running", + "title": "一间跳:轻灵出头", + "category": "shape", + "patternType": "running_shape", + "phase": ["opening", "middlegame"], + "levels": ["beginner", "intermediate", "advanced"], + "regions": ["side", "center"], + "tags": ["一间跳", "出头", "轻棋", "跑棋", "形状"], + "aliases": ["one-space jump", "ikken tobi", "running jump"], + "boardSignals": ["jump", "running", "shape"], + "triggers": { + "moveFeatures": ["side", "center"], + "candidateFeatures": ["jump", "center"], + "pvFeatures": ["running", "shape"], + "contextTags": ["出头", "轻棋", "一间跳", "跑棋"], + "minLossScore": 1, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "running-group", + "canonicalMoves": ["one-space-jump", "running", "keep-light"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "一间跳出", + "mainLine": "用一间跳保持轻灵,同时向中央争取出路。", + "whenToChoose": "己方棋还没有眼,需要出头但不能走重。" + }, + { + "name": "压迫反击", + "mainLine": "对方可能靠近压迫,一间跳后要准备继续轻处理。", + "whenToChoose": "对方厚,自己不能硬碰。" + } + ], + "teaching": { + "recognition": "弱棋逃跑时,一间跳常比直接长更有速度。", + "correctIdea": "轻棋的目标是出头和腾挪,不是每一步都连接得死死的。", + "memoryCue": "弱棋要跑,先找一间跳。", + "commonMistake": "被攻击时只会粘和长,把棋补重。", + "drill": "复盘被攻的大龙,标出有没有一间跳出头点。" + } + }, + { + "id": "shape_keima_light_escape", + "title": "小飞:速度与断点并存", + "category": "shape", + "patternType": "running_shape", + "phase": ["opening", "middlegame"], + "levels": ["intermediate", "advanced"], + "regions": ["side", "center", "corner"], + "tags": ["小飞", "轻棋", "断点", "出头", "形状"], + "aliases": ["small knight move", "keima", "knight move"], + "boardSignals": ["knight-move", "running", "cut"], + "triggers": { + "moveFeatures": ["side", "center"], + "candidateFeatures": ["knight-move", "center"], + "pvFeatures": ["running", "cut"], + "contextTags": ["小飞", "轻棋", "出头", "断点"], + "minLossScore": 1, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "running-group", + "canonicalMoves": ["small-knight", "cut-risk", "light-shape"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "小飞出头", + "mainLine": "小飞比一间跳更有方向性,也更容易保留反击。", + "whenToChoose": "对方切断不严厉,自己需要速度。" + }, + { + "name": "防切补强", + "mainLine": "如果对方切断严厉,小飞后要准备补断或弃子转换。", + "whenToChoose": "局部气紧,断点会变成实战问题。" + } + ], + "teaching": { + "recognition": "小飞是轻灵形,但天然有断点。", + "correctIdea": "会下小飞的人,心里要提前知道被切怎么办。", + "memoryCue": "小飞有速度,也要看断点。", + "commonMistake": "只看到小飞漂亮,没读对方切断。", + "drill": "每次小飞后,读一遍对方切断的最强手。" + } + }, + { + "id": "shape_cap_weak_group", + "title": "帽子:压低弱棋方向", + "category": "shape", + "patternType": "attack_shape", + "phase": ["middlegame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["side", "center"], + "tags": ["帽", "攻击", "压迫", "弱棋", "方向"], + "aliases": ["cap", "boshi", "capping weak group"], + "boardSignals": ["center", "attack", "running"], + "triggers": { + "moveFeatures": ["center", "side"], + "candidateFeatures": ["center", "vital-point"], + "pvFeatures": ["running", "attack"], + "contextTags": ["攻击", "帽", "弱棋", "方向"], + "minLossScore": 1.5, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "weak-group", + "canonicalMoves": ["cap", "limit-running", "profit-while-attacking"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "帽住出路", + "mainLine": "从上方压住弱棋,让对方出头困难,同时扩大己方势力。", + "whenToChoose": "对方弱棋没有眼,自己外侧较厚。" + }, + { + "name": "宽攻", + "mainLine": "不贴身追杀,而是在外面限制方向。", + "whenToChoose": "直接接触会帮对方变强。" + } + ], + "teaching": { + "recognition": "攻击弱棋时,帽住方向常比贴身打吃更高级。", + "correctIdea": "攻击的目标是获利,不一定是杀棋。", + "memoryCue": "弱棋要跑,帽住它的头。", + "commonMistake": "贴身追杀,让对方借力做活。", + "drill": "复盘每次攻击,写下你是想杀棋、围空还是逼对方逃。" + } + }, + { + "id": "joseki_sanrensei_invasion_reduction", + "title": "三连星:打入与浅消", + "category": "joseki", + "patternType": "fuseki_pattern", + "phase": ["opening", "middlegame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["side", "center"], + "tags": ["三连星", "打入", "浅消", "模样", "布局"], + "aliases": ["sanrensei", "three star points", "framework reduction"], + "boardSignals": ["side", "center", "framework", "invasion"], + "triggers": { + "moveFeatures": ["side", "center"], + "candidateFeatures": ["side", "third-line", "center"], + "pvFeatures": ["invasion", "running"], + "contextTags": ["三连星", "模样", "打入", "浅消"], + "minMoveNumber": 10, + "maxMoveNumber": 100 + }, + "shape": { + "anchor": "whole-board", + "canonicalMoves": ["three-star-framework", "reduction", "invasion"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "早期浅消", + "mainLine": "在模样还没厚到能攻击前,轻轻压缩发展。", + "whenToChoose": "自己没有足够支援深入打入。" + }, + { + "name": "三线打入", + "mainLine": "在对方框架中留下活棋或腾挪空间。", + "whenToChoose": "对方边上空大,但封锁还不完整。" + } + ], + "teaching": { + "recognition": "三连星不是固定定式,而是大模样作战体系。", + "correctIdea": "应对三连星要判断时机:早消、晚入、或先抢别处。", + "memoryCue": "模样越厚,越要浅;还没封,就可入。", + "commonMistake": "等对方全厚了才打入,进去后只剩逃命。", + "drill": "遇到三连星,把可浅消点和可打入点分别标出来。" + } + }, + { + "id": "joseki_chinese_fuseki_low_approach_choice", + "title": "中国流:低挂后的方向选择", + "category": "joseki", + "patternType": "fuseki_pattern", + "phase": ["opening"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner", "side"], + "tags": ["中国流", "低挂", "夹攻", "拆边", "布局", "方向"], + "aliases": ["Chinese fuseki", "low approach against Chinese", "opening direction"], + "boardSignals": ["corner", "side", "framework", "approach"], + "triggers": { + "moveFeatures": ["approach", "side"], + "candidateFeatures": ["third-line", "side", "corner"], + "pvFeatures": ["framework", "running"], + "contextTags": ["中国流", "布局", "低挂", "方向"], + "minMoveNumber": 5, + "maxMoveNumber": 70 + }, + "shape": { + "anchor": "whole-board", + "canonicalMoves": ["chinese-fuseki", "low-approach", "pincer-or-extension"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "夹攻进入体系", + "mainLine": "中国流常通过夹攻把挂角子赶向己方框架。", + "whenToChoose": "己方低位配置完整,想主动攻击。" + }, + { + "name": "简明拆边", + "mainLine": "挂角方可选择拆边或转身,避免被带入对方强势方向。", + "whenToChoose": "不想在对方准备好的战场开战。" + } + ], + "teaching": { + "recognition": "中国流的关键不是某个角部定式,而是把对方引向自己的模样。", + "correctIdea": "低挂后要问:我是进入对方框架,还是转向外面?", + "memoryCue": "中国流看方向,不只看角。", + "commonMistake": "只背挂角定式,没看对方整体布局意图。", + "drill": "复盘中国流时,画出对方希望你进入的方向。" + } + }, + { + "id": "joseki_33_invasion_timing_against_thickness", + "title": "点三三时机:别帮对方厚起来", + "category": "joseki", + "patternType": "corner_joseki", + "phase": ["opening", "middlegame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner"], + "tags": ["点三三", "时机", "厚势", "方向", "AI定式"], + "aliases": ["3-3 timing", "early 3-3", "invasion timing"], + "boardSignals": ["corner", "3-3", "4-4", "influence"], + "triggers": { + "moveFeatures": ["corner", "3-3"], + "candidateFeatures": ["3-3", "corner"], + "pvFeatures": ["corner", "influence"], + "contextTags": ["点三三", "厚势", "方向", "时机"], + "minMoveNumber": 1, + "maxMoveNumber": 120 + }, + "shape": { + "anchor": "nearest-corner", + "canonicalMoves": ["4-4", "3-3", "direction-choice", "outside-influence"], + "gtpExamples": ["Q16 R17", "D16 C17"], + "symmetry": "corner" + }, + "variations": [ + { + "name": "早期点三三", + "mainLine": "早早拿角,承认对方外势,但对方外势未必能马上发挥。", + "whenToChoose": "角部实地价值确定,外面没有明显被攻击目标。" + }, + { + "name": "延后点三三", + "mainLine": "等外面配置变化后再入侵,避免帮对方自然成厚。", + "whenToChoose": "对方外势会直接攻击己方弱棋。" + } + ], + "teaching": { + "recognition": "点三三不只是局部活角,还是把外势交给对方。", + "correctIdea": "KataGo 推荐点三三时,看它是否同时认为对方外势用不上。", + "memoryCue": "拿角可以,别送对方有用厚势。", + "commonMistake": "学 AI 早三三,却不看外面有没有己方弱棋。", + "drill": "每次点三三后,写下对方外势下一步能攻击哪里。" + } + }, + { + "id": "shape_probe_then_tenuki_aji", + "title": "保留味道:不急着交换", + "category": "shape", + "patternType": "aji_management", + "phase": ["opening", "middlegame"], + "levels": ["intermediate", "advanced", "dan"], + "regions": ["corner", "side", "center"], + "tags": ["味", "味消", "交换", "先手", "形状"], + "aliases": ["aji", "bad exchange", "keep options"], + "boardSignals": ["probe", "shape", "connection"], + "triggers": { + "moveFeatures": ["contact"], + "candidateFeatures": ["center", "side", "vital-point"], + "pvFeatures": ["shape", "tenuki"], + "contextTags": ["味", "交换", "先手", "味消"], + "minLossScore": 1, + "judgements": ["inaccuracy", "mistake", "blunder"] + }, + "shape": { + "anchor": "local-shape", + "canonicalMoves": ["keep-aji", "avoid-bad-exchange", "tenuki"], + "gtpExamples": [], + "symmetry": "local" + }, + "variations": [ + { + "name": "保留", + "mainLine": "不急着走局部交换,让对方始终担心后续手段。", + "whenToChoose": "现在交换会帮对方补强。" + }, + { + "name": "兑现", + "mainLine": "当局部手段已经变成先手或能获得实利时,再兑现味道。", + "whenToChoose": "对方薄,且交换后不会给对方好形。" + } + ], + "teaching": { + "recognition": "有些看似先手的交换,走了反而把自己的味消掉。", + "correctIdea": "味道的价值在于选择权,不是看到就马上兑现。", + "memoryCue": "有味先留,能赚再动。", + "commonMistake": "习惯性窥、扳、交换,帮对方补厚。", + "drill": "复盘所有先手交换,标注它是赚、亏,还是味消。" + } + } +] diff --git a/data/knowledge/source-registry.json b/data/knowledge/source-registry.json new file mode 100644 index 0000000..bfa7cfb --- /dev/null +++ b/data/knowledge/source-registry.json @@ -0,0 +1,101 @@ +[ + { + "id": "github_sanderland_tsumego", + "name": "Ten Thousand Tsumego", + "url": "https://github.com/sanderland/tsumego", + "license": "MIT-style LICENSE in repository", + "status": "taxonomy-reference-only", + "why": "The repository documents a large tsumego app and exposes useful training categories, but its problem folders reference classic book collections. GoMentor should not import raw positions from those books without a separate rights review.", + "usedFor": ["life-death taxonomy", "difficulty ladder", "training-card coverage planning"] + }, + { + "id": "github_szalonysamuraj_joseki_master", + "name": "Joseki-Master", + "url": "https://github.com/SzalonySamuraj/Joseki-Master", + "license": "MIT", + "status": "safe-for-structural-reference", + "why": "The repository is MIT licensed and its collection names give a compact joseki training taxonomy. GoMentor uses original teaching summaries rather than copying collection data verbatim.", + "usedFor": ["star-point joseki families", "komoku joseki families", "joseki training taxonomy"] + }, + { + "id": "github_billyellow_kogo", + "name": "Kogo's Joseki Dictionary Chinese SGF", + "url": "https://github.com/billyellow/Kogo-s-Joseki-Dictionary", + "license": "not found", + "status": "do-not-import", + "why": "The repository contains a translated SGF but no explicit license. It is useful only as a reminder that joseki content needs careful licensing.", + "usedFor": ["source-risk review"] + }, + { + "id": "github_online_go_godojo", + "name": "OGS godojo-server", + "url": "https://github.com/online-go/godojo-server", + "license": "not found", + "status": "do-not-import", + "why": "The README identifies it as a server for OGS joseki features, but no license was found during review.", + "usedFor": ["source-risk review", "future integration idea"] + }, + { + "id": "github_kovarex_tsumego_hero", + "name": "tsumego-hero refactor", + "url": "https://github.com/kovarex/tsumego-hero", + "license": "not found", + "status": "architecture-reference-only", + "why": "The repository is a modernized tsumego-hero codebase. It is useful for product and data-flow ideas, not for importing problem data.", + "usedFor": ["tsumego product architecture", "source-risk review"] + }, + { + "id": "web_josekipedia_games", + "name": "Josekipedia game collection", + "url": "https://www.josekipedia.com/game/info.php", + "license": "Creative Commons stated for game database", + "status": "future-import-candidate", + "why": "Josekipedia states that its game database is freely distributed under a Creative Commons License and discards comments/variations. This may be useful for future joseki frequency mining, but GoMentor currently only records it as a potential source until the exact CC terms and attribution path are reviewed.", + "usedFor": ["future joseki frequency mining", "license review"] + }, + { + "id": "web_wikipedia_life_death", + "name": "Wikipedia: Life and death", + "url": "https://en.wikipedia.org/wiki/Life_and_death", + "license": "CC BY-SA 4.0", + "status": "concept-reference-only", + "why": "The page summarizes fundamental life-and-death concepts such as two eyes, dead groups, unsettled groups, and eye-space. GoMentor uses original teaching text and does not copy article prose.", + "usedFor": ["life-death concept coverage", "beginner terminology checks"] + }, + { + "id": "web_wikipedia_go_rules", + "name": "Wikipedia: Go game rules and strategy overview", + "url": "https://en.wikipedia.org/wiki/Go_(game)", + "license": "CC BY-SA 4.0", + "status": "concept-reference-only", + "why": "The article summarizes life status, semeai, corners/sides opening priority, joseki, ko, and seki. GoMentor uses it only to cross-check broad coverage.", + "usedFor": ["rules terminology", "strategy coverage checklist"] + }, + { + "id": "web_goproblems_best_practices", + "name": "goproblems.com best practices", + "url": "https://www.goproblems.com/article/bestpractices", + "license": "not found", + "status": "taxonomy-reference-only", + "why": "The page describes common problem classes such as life-and-death and tesuji. It is useful as a taxonomy reference, but GoMentor does not import problem text or diagrams.", + "usedFor": ["problem taxonomy", "training UX terminology"] + }, + { + "id": "web_ogs_cc_discussion", + "name": "Online Go Forum: Go books/content in the Creative Commons", + "url": "https://forums.online-go.com/t/go-books-content-in-the-creative-commons/24517", + "license": "forum content, not a data source", + "status": "source-risk-reference", + "why": "The discussion highlights that Internet availability and archive copies do not imply Creative Commons rights. GoMentor uses this as a policy reminder to avoid importing classic book scans or problem collections without explicit permission.", + "usedFor": ["copyright review policy"] + }, + { + "id": "gomentor_original_training_catalog", + "name": "GoMentor original common-pattern training catalog", + "url": "local:data/knowledge/training-catalog.json", + "license": "MIT project content", + "status": "original-common-pattern", + "why": "The catalog contains original teaching reconstructions of common joseki, life-and-death, and tesuji patterns. It deliberately avoids copying book diagrams, problem ordering, SGF comments, or website explanations.", + "usedFor": ["joseki matching", "life-death training recommendations", "tesuji training recommendations", "teacher explanation payloads"] + } +] diff --git a/data/knowledge/training-catalog.json b/data/knowledge/training-catalog.json new file mode 100644 index 0000000..a461d22 --- /dev/null +++ b/data/knowledge/training-catalog.json @@ -0,0 +1,17008 @@ +{ + "version": 1, + "generatedAt": "2026-04-26", + "sourcePolicy": { + "defaultSourceKind": "common-pattern", + "rule": "Entries are original teaching reconstructions of public/common Go patterns. Do not copy book diagrams, problem ordering, SGF comments, or website explanations without explicit license review.", + "allowedKinds": [ + "original", + "common-pattern", + "licensed-source" + ] + }, + "josekiLines": [ + { + "id": "joseki_star_33_basic_line", + "title": "星位点三三:简明主线", + "family": "star_33_invasion", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "R17", + "R16", + "Q17" + ], + "normalizedFeatures": [ + "corner", + "3-3", + "4-4", + "星位", + "点三三", + "角部", + "定式" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "Q16 - R17 - R16 - Q17", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看星位点三三周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "点三三", + "角部", + "定式" + ] + }, + { + "id": "joseki_star_33_influence_line", + "title": "星位点三三:取势分支", + "family": "star_33_invasion", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "R17", + "R16", + "Q17", + "P14" + ], + "normalizedFeatures": [ + "corner", + "3-3", + "4-4", + "星位", + "点三三", + "角部", + "定式" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "Q16 - R17 - R16 - Q17", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看星位点三三周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "点三三", + "角部", + "定式" + ] + }, + { + "id": "joseki_star_33_territory_line", + "title": "星位点三三:实地分支", + "family": "star_33_invasion", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "R17", + "R16", + "Q17", + "R18" + ], + "normalizedFeatures": [ + "corner", + "3-3", + "4-4", + "星位", + "点三三", + "角部", + "定式" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "Q16 - R17 - R16 - Q17", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看星位点三三周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "点三三", + "角部", + "定式" + ] + }, + { + "id": "joseki_star_33_tenuki_timing", + "title": "星位点三三:脱先时机", + "family": "star_33_invasion", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "R17", + "R16", + "Q17", + "K10" + ], + "normalizedFeatures": [ + "corner", + "3-3", + "4-4", + "星位", + "点三三", + "角部", + "定式" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "Q16 - R17 - R16 - Q17", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看星位点三三周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "点三三", + "角部", + "定式" + ] + }, + { + "id": "joseki_star_low_approach_basic_line", + "title": "星位低挂:简明主线", + "family": "star_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "O17", + "P16", + "Q14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "低挂", + "夹攻", + "定式" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "Q16 - O17 - P16 - Q14", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看星位低挂周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "低挂", + "夹攻", + "定式" + ] + }, + { + "id": "joseki_star_low_approach_influence_line", + "title": "星位低挂:取势分支", + "family": "star_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "O17", + "P16", + "Q14", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "低挂", + "夹攻", + "定式" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "Q16 - O17 - P16 - Q14", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看星位低挂周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "低挂", + "夹攻", + "定式" + ] + }, + { + "id": "joseki_star_low_approach_territory_line", + "title": "星位低挂:实地分支", + "family": "star_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "O17", + "P16", + "Q14", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "低挂", + "夹攻", + "定式" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "Q16 - O17 - P16 - Q14", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看星位低挂周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "低挂", + "夹攻", + "定式" + ] + }, + { + "id": "joseki_star_low_approach_tenuki_timing", + "title": "星位低挂:脱先时机", + "family": "star_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "O17", + "P16", + "Q14", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "低挂", + "夹攻", + "定式" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "Q16 - O17 - P16 - Q14", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看星位低挂周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "低挂", + "夹攻", + "定式" + ] + }, + { + "id": "joseki_star_high_approach_basic_line", + "title": "星位高挂:简明主线", + "family": "star_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "O16", + "P17", + "Q14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "高挂", + "取势", + "定式" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "Q16 - O16 - P17 - Q14", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看星位高挂周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "高挂", + "取势", + "定式" + ] + }, + { + "id": "joseki_star_high_approach_influence_line", + "title": "星位高挂:取势分支", + "family": "star_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "O16", + "P17", + "Q14", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "高挂", + "取势", + "定式" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "Q16 - O16 - P17 - Q14", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看星位高挂周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "高挂", + "取势", + "定式" + ] + }, + { + "id": "joseki_star_high_approach_territory_line", + "title": "星位高挂:实地分支", + "family": "star_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "O16", + "P17", + "Q14", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "高挂", + "取势", + "定式" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "Q16 - O16 - P17 - Q14", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看星位高挂周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "高挂", + "取势", + "定式" + ] + }, + { + "id": "joseki_star_high_approach_tenuki_timing", + "title": "星位高挂:脱先时机", + "family": "star_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "O16", + "P17", + "Q14", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "高挂", + "取势", + "定式" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "Q16 - O16 - P17 - Q14", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看星位高挂周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "高挂", + "取势", + "定式" + ] + }, + { + "id": "joseki_komoku_low_approach_basic_line", + "title": "小目低挂:简明主线", + "family": "komoku_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O3", + "R3", + "P5" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "低挂", + "滑", + "夹攻" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "Q4 - O3 - R3 - P5", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看小目低挂周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "低挂", + "滑", + "夹攻" + ] + }, + { + "id": "joseki_komoku_low_approach_influence_line", + "title": "小目低挂:取势分支", + "family": "komoku_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O3", + "R3", + "P5", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "低挂", + "滑", + "夹攻" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "Q4 - O3 - R3 - P5", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看小目低挂周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "低挂", + "滑", + "夹攻" + ] + }, + { + "id": "joseki_komoku_low_approach_territory_line", + "title": "小目低挂:实地分支", + "family": "komoku_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O3", + "R3", + "P5", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "低挂", + "滑", + "夹攻" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "Q4 - O3 - R3 - P5", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看小目低挂周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "低挂", + "滑", + "夹攻" + ] + }, + { + "id": "joseki_komoku_low_approach_tenuki_timing", + "title": "小目低挂:脱先时机", + "family": "komoku_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O3", + "R3", + "P5", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "低挂", + "滑", + "夹攻" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "Q4 - O3 - R3 - P5", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看小目低挂周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "低挂", + "滑", + "夹攻" + ] + }, + { + "id": "joseki_komoku_high_approach_basic_line", + "title": "小目高挂:简明主线", + "family": "komoku_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O4", + "P6", + "R6" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "高挂", + "夹攻", + "定式" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "Q4 - O4 - P6 - R6", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看小目高挂周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "高挂", + "夹攻", + "定式" + ] + }, + { + "id": "joseki_komoku_high_approach_influence_line", + "title": "小目高挂:取势分支", + "family": "komoku_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O4", + "P6", + "R6", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "高挂", + "夹攻", + "定式" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "Q4 - O4 - P6 - R6", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看小目高挂周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "高挂", + "夹攻", + "定式" + ] + }, + { + "id": "joseki_komoku_high_approach_territory_line", + "title": "小目高挂:实地分支", + "family": "komoku_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O4", + "P6", + "R6", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "高挂", + "夹攻", + "定式" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "Q4 - O4 - P6 - R6", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看小目高挂周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "高挂", + "夹攻", + "定式" + ] + }, + { + "id": "joseki_komoku_high_approach_tenuki_timing", + "title": "小目高挂:脱先时机", + "family": "komoku_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O4", + "P6", + "R6", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "高挂", + "夹攻", + "定式" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "Q4 - O4 - P6 - R6", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看小目高挂周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "高挂", + "夹攻", + "定式" + ] + }, + { + "id": "joseki_immediate_33_basic_line", + "title": "AI 时代早早点三三:简明主线", + "family": "ai_33_timing", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "C17", + "C16", + "D17" + ], + "normalizedFeatures": [ + "corner", + "3-3", + "4-4", + "点三三", + "AI定式", + "实地", + "时机" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "D16 - C17 - C16 - D17", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看AI 时代早早点三三周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "点三三", + "AI定式", + "实地", + "时机" + ] + }, + { + "id": "joseki_immediate_33_influence_line", + "title": "AI 时代早早点三三:取势分支", + "family": "ai_33_timing", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "C17", + "C16", + "D17", + "P14" + ], + "normalizedFeatures": [ + "corner", + "3-3", + "4-4", + "点三三", + "AI定式", + "实地", + "时机" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "D16 - C17 - C16 - D17", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看AI 时代早早点三三周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "点三三", + "AI定式", + "实地", + "时机" + ] + }, + { + "id": "joseki_immediate_33_territory_line", + "title": "AI 时代早早点三三:实地分支", + "family": "ai_33_timing", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "C17", + "C16", + "D17", + "R18" + ], + "normalizedFeatures": [ + "corner", + "3-3", + "4-4", + "点三三", + "AI定式", + "实地", + "时机" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "D16 - C17 - C16 - D17", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看AI 时代早早点三三周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "点三三", + "AI定式", + "实地", + "时机" + ] + }, + { + "id": "joseki_immediate_33_tenuki_timing", + "title": "AI 时代早早点三三:脱先时机", + "family": "ai_33_timing", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "C17", + "C16", + "D17", + "K10" + ], + "normalizedFeatures": [ + "corner", + "3-3", + "4-4", + "点三三", + "AI定式", + "实地", + "时机" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "D16 - C17 - C16 - D17", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看AI 时代早早点三三周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "点三三", + "AI定式", + "实地", + "时机" + ] + }, + { + "id": "joseki_sanrensei_basic_line", + "title": "三连星:简明主线", + "family": "sanrensei", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "Q16", + "K16", + "K14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "三连星", + "模样", + "打入", + "浅消" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "D16 - Q16 - K16 - K14", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看三连星周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "三连星", + "模样", + "打入", + "浅消" + ] + }, + { + "id": "joseki_sanrensei_influence_line", + "title": "三连星:取势分支", + "family": "sanrensei", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "Q16", + "K16", + "K14", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "三连星", + "模样", + "打入", + "浅消" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "D16 - Q16 - K16 - K14", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看三连星周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "三连星", + "模样", + "打入", + "浅消" + ] + }, + { + "id": "joseki_sanrensei_territory_line", + "title": "三连星:实地分支", + "family": "sanrensei", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "Q16", + "K16", + "K14", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "三连星", + "模样", + "打入", + "浅消" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "D16 - Q16 - K16 - K14", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看三连星周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "三连星", + "模样", + "打入", + "浅消" + ] + }, + { + "id": "joseki_sanrensei_tenuki_timing", + "title": "三连星:脱先时机", + "family": "sanrensei", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "Q16", + "K16", + "K14", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "三连星", + "模样", + "打入", + "浅消" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "D16 - Q16 - K16 - K14", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看三连星周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "三连星", + "模样", + "打入", + "浅消" + ] + }, + { + "id": "joseki_chinese_opening_basic_line", + "title": "中国流:简明主线", + "family": "chinese_opening", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D4", + "Q16", + "Q10", + "O17" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "中国流", + "低挂", + "方向", + "布局" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "D4 - Q16 - Q10 - O17", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看中国流周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "中国流", + "低挂", + "方向", + "布局" + ] + }, + { + "id": "joseki_chinese_opening_influence_line", + "title": "中国流:取势分支", + "family": "chinese_opening", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D4", + "Q16", + "Q10", + "O17", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "中国流", + "低挂", + "方向", + "布局" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "D4 - Q16 - Q10 - O17", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看中国流周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "中国流", + "低挂", + "方向", + "布局" + ] + }, + { + "id": "joseki_chinese_opening_territory_line", + "title": "中国流:实地分支", + "family": "chinese_opening", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D4", + "Q16", + "Q10", + "O17", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "中国流", + "低挂", + "方向", + "布局" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "D4 - Q16 - Q10 - O17", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看中国流周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "中国流", + "低挂", + "方向", + "布局" + ] + }, + { + "id": "joseki_chinese_opening_tenuki_timing", + "title": "中国流:脱先时机", + "family": "chinese_opening", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D4", + "Q16", + "Q10", + "O17", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "中国流", + "低挂", + "方向", + "布局" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "D4 - Q16 - Q10 - O17", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看中国流周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "中国流", + "低挂", + "方向", + "布局" + ] + }, + { + "id": "joseki_kobayashi_basic_line", + "title": "小林流:简明主线", + "family": "kobayashi", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "D16", + "Q10", + "O3" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "小林流", + "小目", + "挂角", + "方向" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "Q4 - D16 - Q10 - O3", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看小林流周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小林流", + "小目", + "挂角", + "方向" + ] + }, + { + "id": "joseki_kobayashi_influence_line", + "title": "小林流:取势分支", + "family": "kobayashi", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "D16", + "Q10", + "O3", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "小林流", + "小目", + "挂角", + "方向" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "Q4 - D16 - Q10 - O3", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看小林流周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小林流", + "小目", + "挂角", + "方向" + ] + }, + { + "id": "joseki_kobayashi_territory_line", + "title": "小林流:实地分支", + "family": "kobayashi", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "D16", + "Q10", + "O3", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "小林流", + "小目", + "挂角", + "方向" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "Q4 - D16 - Q10 - O3", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看小林流周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小林流", + "小目", + "挂角", + "方向" + ] + }, + { + "id": "joseki_kobayashi_tenuki_timing", + "title": "小林流:脱先时机", + "family": "kobayashi", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "D16", + "Q10", + "O3", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "小林流", + "小目", + "挂角", + "方向" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "Q4 - D16 - Q10 - O3", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看小林流周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小林流", + "小目", + "挂角", + "方向" + ] + }, + { + "id": "joseki_mini_chinese_basic_line", + "title": "迷你中国流:简明主线", + "family": "mini_chinese", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D4", + "Q16", + "K4", + "O17" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "迷你中国流", + "小目", + "星位", + "布局" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "D4 - Q16 - K4 - O17", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看迷你中国流周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "迷你中国流", + "小目", + "星位", + "布局" + ] + }, + { + "id": "joseki_mini_chinese_influence_line", + "title": "迷你中国流:取势分支", + "family": "mini_chinese", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D4", + "Q16", + "K4", + "O17", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "迷你中国流", + "小目", + "星位", + "布局" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "D4 - Q16 - K4 - O17", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看迷你中国流周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "迷你中国流", + "小目", + "星位", + "布局" + ] + }, + { + "id": "joseki_mini_chinese_territory_line", + "title": "迷你中国流:实地分支", + "family": "mini_chinese", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D4", + "Q16", + "K4", + "O17", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "迷你中国流", + "小目", + "星位", + "布局" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "D4 - Q16 - K4 - O17", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看迷你中国流周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "迷你中国流", + "小目", + "星位", + "布局" + ] + }, + { + "id": "joseki_mini_chinese_tenuki_timing", + "title": "迷你中国流:脱先时机", + "family": "mini_chinese", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D4", + "Q16", + "K4", + "O17", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "迷你中国流", + "小目", + "星位", + "布局" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "D4 - Q16 - K4 - O17", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看迷你中国流周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "迷你中国流", + "小目", + "星位", + "布局" + ] + }, + { + "id": "joseki_nirensei_basic_line", + "title": "二连星:简明主线", + "family": "nirensei", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "Q16", + "K16", + "D10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "二连星", + "大场", + "分投", + "方向" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "D16 - Q16 - K16 - D10", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看二连星周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "二连星", + "大场", + "分投", + "方向" + ] + }, + { + "id": "joseki_nirensei_influence_line", + "title": "二连星:取势分支", + "family": "nirensei", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "Q16", + "K16", + "D10", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "二连星", + "大场", + "分投", + "方向" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "D16 - Q16 - K16 - D10", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看二连星周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "二连星", + "大场", + "分投", + "方向" + ] + }, + { + "id": "joseki_nirensei_territory_line", + "title": "二连星:实地分支", + "family": "nirensei", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "Q16", + "K16", + "D10", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "二连星", + "大场", + "分投", + "方向" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "D16 - Q16 - K16 - D10", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看二连星周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "二连星", + "大场", + "分投", + "方向" + ] + }, + { + "id": "joseki_nirensei_tenuki_timing", + "title": "二连星:脱先时机", + "family": "nirensei", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "Q16", + "K16", + "D10", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "二连星", + "大场", + "分投", + "方向" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "D16 - Q16 - K16 - D10", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看二连星周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "二连星", + "大场", + "分投", + "方向" + ] + }, + { + "id": "joseki_komoku_enclosure_basic_line", + "title": "小目守角:简明主线", + "family": "komoku_enclosure", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O3", + "Q6", + "R10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "守角", + "拆边", + "时机" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "Q4 - O3 - Q6 - R10", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看小目守角周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "守角", + "拆边", + "时机" + ] + }, + { + "id": "joseki_komoku_enclosure_influence_line", + "title": "小目守角:取势分支", + "family": "komoku_enclosure", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O3", + "Q6", + "R10", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "守角", + "拆边", + "时机" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "Q4 - O3 - Q6 - R10", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看小目守角周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "守角", + "拆边", + "时机" + ] + }, + { + "id": "joseki_komoku_enclosure_territory_line", + "title": "小目守角:实地分支", + "family": "komoku_enclosure", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O3", + "Q6", + "R10", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "守角", + "拆边", + "时机" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "Q4 - O3 - Q6 - R10", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看小目守角周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "守角", + "拆边", + "时机" + ] + }, + { + "id": "joseki_komoku_enclosure_tenuki_timing", + "title": "小目守角:脱先时机", + "family": "komoku_enclosure", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O3", + "Q6", + "R10", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "3-4", + "小目", + "守角", + "拆边", + "时机" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "Q4 - O3 - Q6 - R10", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看小目守角周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "小目", + "守角", + "拆边", + "时机" + ] + }, + { + "id": "joseki_star_attachment_basic_line", + "title": "星位靠压:简明主线", + "family": "star_attachment", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "Q14", + "R14", + "P16" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "靠压", + "转换", + "定式" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "Q16 - Q14 - R14 - P16", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看星位靠压周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "靠压", + "转换", + "定式" + ] + }, + { + "id": "joseki_star_attachment_influence_line", + "title": "星位靠压:取势分支", + "family": "star_attachment", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "Q14", + "R14", + "P16", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "靠压", + "转换", + "定式" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "Q16 - Q14 - R14 - P16", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看星位靠压周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "靠压", + "转换", + "定式" + ] + }, + { + "id": "joseki_star_attachment_territory_line", + "title": "星位靠压:实地分支", + "family": "star_attachment", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "Q14", + "R14", + "P16", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "靠压", + "转换", + "定式" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "Q16 - Q14 - R14 - P16", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看星位靠压周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "靠压", + "转换", + "定式" + ] + }, + { + "id": "joseki_star_attachment_tenuki_timing", + "title": "星位靠压:脱先时机", + "family": "star_attachment", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "Q14", + "R14", + "P16", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "靠压", + "转换", + "定式" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "Q16 - Q14 - R14 - P16", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看星位靠压周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "星位", + "靠压", + "转换", + "定式" + ] + }, + { + "id": "joseki_pincer_after_approach_basic_line", + "title": "挂角后一间夹攻:简明主线", + "family": "pincer_joseki", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "F17", + "F15", + "C17" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "挂角", + "夹攻", + "治孤", + "方向" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "D16 - F17 - F15 - C17", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看挂角后一间夹攻周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "挂角", + "夹攻", + "治孤", + "方向" + ] + }, + { + "id": "joseki_pincer_after_approach_influence_line", + "title": "挂角后一间夹攻:取势分支", + "family": "pincer_joseki", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "F17", + "F15", + "C17", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "挂角", + "夹攻", + "治孤", + "方向" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "D16 - F17 - F15 - C17", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看挂角后一间夹攻周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "挂角", + "夹攻", + "治孤", + "方向" + ] + }, + { + "id": "joseki_pincer_after_approach_territory_line", + "title": "挂角后一间夹攻:实地分支", + "family": "pincer_joseki", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "F17", + "F15", + "C17", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "挂角", + "夹攻", + "治孤", + "方向" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "D16 - F17 - F15 - C17", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看挂角后一间夹攻周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "挂角", + "夹攻", + "治孤", + "方向" + ] + }, + { + "id": "joseki_pincer_after_approach_tenuki_timing", + "title": "挂角后一间夹攻:脱先时机", + "family": "pincer_joseki", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "D16", + "F17", + "F15", + "C17", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "挂角", + "夹攻", + "治孤", + "方向" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "D16 - F17 - F15 - C17", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看挂角后一间夹攻周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "挂角", + "夹攻", + "治孤", + "方向" + ] + }, + { + "id": "joseki_large_knight_enclosure_basic_line", + "title": "大飞守角与侵消:简明主线", + "family": "enclosure_reduction", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "beginner", + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O6", + "R10", + "O3" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "大飞守角", + "侵消", + "挂角", + "布局" + ], + "branches": [ + { + "name": "简明主线", + "sequence": "Q4 - O6 - R10 - O3", + "whenToChoose": "周围没有强烈战斗时,用最稳的收束保留先手。", + "warning": "别因为背了定式就忽略下一处大场。" + } + ], + "decisionRules": [ + "先看大飞守角与侵消周围厚薄,再决定局部收束还是全局转身。", + "周围没有强烈战斗时,用最稳的收束保留先手。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "别因为背了定式就忽略下一处大场。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "大飞守角", + "侵消", + "挂角", + "布局" + ] + }, + { + "id": "joseki_large_knight_enclosure_influence_line", + "title": "大飞守角与侵消:取势分支", + "family": "enclosure_reduction", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O6", + "R10", + "O3", + "P14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "大飞守角", + "侵消", + "挂角", + "布局" + ], + "branches": [ + { + "name": "取势分支", + "sequence": "Q4 - O6 - R10 - O3", + "whenToChoose": "外侧有己方棋子,或者中腹有攻击目标时优先。", + "warning": "外势没有攻击目标时会变成空架子。" + } + ], + "decisionRules": [ + "先看大飞守角与侵消周围厚薄,再决定局部收束还是全局转身。", + "外侧有己方棋子,或者中腹有攻击目标时优先。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "外势没有攻击目标时会变成空架子。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "大飞守角", + "侵消", + "挂角", + "布局" + ] + }, + { + "id": "joseki_large_knight_enclosure_territory_line", + "title": "大飞守角与侵消:实地分支", + "family": "enclosure_reduction", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O6", + "R10", + "O3", + "R18" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "大飞守角", + "侵消", + "挂角", + "布局" + ], + "branches": [ + { + "name": "实地分支", + "sequence": "Q4 - O6 - R10 - O3", + "whenToChoose": "局面细、需要稳定现金价值时选择。", + "warning": "贪实地容易把厚势送给对方。" + } + ], + "decisionRules": [ + "先看大飞守角与侵消周围厚薄,再决定局部收束还是全局转身。", + "局面细、需要稳定现金价值时选择。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "贪实地容易把厚势送给对方。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "大飞守角", + "侵消", + "挂角", + "布局" + ] + }, + { + "id": "joseki_large_knight_enclosure_tenuki_timing", + "title": "大飞守角与侵消:脱先时机", + "family": "enclosure_reduction", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O6", + "R10", + "O3", + "K10" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "大飞守角", + "侵消", + "挂角", + "布局" + ], + "branches": [ + { + "name": "脱先时机", + "sequence": "Q4 - O6 - R10 - O3", + "whenToChoose": "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "warning": "局部还薄就脱先,下一手会被对方严厉攻击。" + } + ], + "decisionRules": [ + "先看大飞守角与侵消周围厚薄,再决定局部收束还是全局转身。", + "KataGo 一选明显转向别处,且局部没有被强攻风险时。", + "如果 KataGo 首选和定式记忆冲突,优先相信本局全局价值。" + ], + "commonMistakes": [ + "局部还薄就脱先,下一手会被对方严厉攻击。", + "只背局部手顺,不判断外势方向和先手价值。" + ], + "katagoEraJudgement": "AI 时代定式不是固定答案,而是用候选点、目差和全局配合来选择分支。", + "trainingFocus": [ + "方向判断", + "局部轻重", + "先手价值" + ], + "patternCardIds": [ + "joseki_star_33_invasion_double_hane", + "joseki_star_low_approach_small_knight" + ], + "tags": [ + "大飞守角", + "侵消", + "挂角", + "布局" + ] + }, + { + "id": "joseki_komoku_avalanche_simplified", + "title": "小目大雪崩:现代简化避战", + "family": "komoku_avalanche", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "R6", + "Q6", + "P6", + "R5" + ], + "normalizedFeatures": [ + "corner", + "3-4", + "小目", + "大雪崩", + "夹攻", + "定式" + ], + "branches": [ + { + "name": "简化避战", + "sequence": "Q4 - R6 - Q6 - P6 - R5", + "whenToChoose": "局部变化很复杂、全局没有必要硬拼读秒战时,优先选择能明确安定和转身的分支。", + "warning": "只因为听过大雪崩就硬进复杂变化,容易把全局主动权交出去。" + } + ], + "decisionRules": [ + "先判断角部双方外援,再决定进入复杂战斗还是简化收束。", + "如果外侧没有配合,大雪崩分支应以稳定安定和先手价值为先。", + "KataGo 若明显偏向脱先或简化,不要机械追求传统大变化。" + ], + "commonMistakes": [ + "把大雪崩当成必须背到底的手顺。", + "忽略外侧厚薄,只在局部争一两目。" + ], + "katagoEraJudgement": "AI 时代更重视全局配合;大雪崩可作为读力训练,但实战要看外援、厚薄和先手价值。", + "trainingFocus": [ + "复杂定式取舍", + "外援判断", + "简化能力" + ], + "patternCardIds": [ + "joseki_34_high_approach_one_space_low_pincer", + "joseki_34_low_approach_attach_extend" + ], + "tags": [ + "小目", + "大雪崩", + "雪崩", + "夹攻", + "定式" + ] + }, + { + "id": "joseki_komoku_taisha_simplified", + "title": "小目大斜:方向优先分支", + "family": "komoku_taisha", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "R6", + "Q6", + "R10", + "P5" + ], + "normalizedFeatures": [ + "corner", + "3-4", + "小目", + "大斜", + "太斜", + "定式" + ], + "branches": [ + { + "name": "方向优先", + "sequence": "Q4 - R6 - Q6 - R10 - P5", + "whenToChoose": "角部接触战会牵动右边或上边方向,且己方能获得明确外势目标时。", + "warning": "大斜不是局部名气越大越好,方向不对会变成帮对方整形。" + } + ], + "decisionRules": [ + "先问外势要面向哪一边,再读局部接触次序。", + "对方外援强时,优先考虑简化和脱先价值。", + "KataGo 候选点若不在大斜主线,要先相信全局方向。" + ], + "commonMistakes": [ + "背复杂手顺却不判断厚势方向。", + "在己方外侧薄弱时主动引爆战斗。" + ], + "katagoEraJudgement": "大斜在现代实战中更像方向判断题;AI 候选点会根据全局厚薄大幅改变。", + "trainingFocus": [ + "方向判断", + "接触战次序", + "厚薄判断" + ], + "patternCardIds": [ + "joseki_34_high_approach_one_space_low_pincer", + "joseki_34_low_approach_attach_extend" + ], + "tags": [ + "小目", + "大斜", + "太斜", + "方向", + "定式" + ] + }, + { + "id": "joseki_star_double_approach_choice", + "title": "星位双飞燕:两翼选择", + "family": "star_double_approach", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "intermediate", + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q16", + "R14", + "P17", + "O16", + "Q14" + ], + "normalizedFeatures": [ + "corner", + "4-4", + "星位", + "双飞燕", + "挂角", + "定式" + ], + "branches": [ + { + "name": "两翼取舍", + "sequence": "Q16 - R14 - P17 - O16 - Q14", + "whenToChoose": "双方在星位两侧同时施压,需要先分清哪一翼更急、哪一边更有发展。", + "warning": "平均应对两边常常两边都不满意。" + } + ], + "decisionRules": [ + "双飞燕先看哪一边关系到全局大模样或弱棋。", + "局部不是要全部吃住,而是选择一边安定、一边留味。", + "KataGo 若给出肩冲或脱先,要检查是不是全局价值大于角部应对。" + ], + "commonMistakes": [ + "两边都想照顾,结果被对方轻灵处理。", + "把星位双飞燕当成固定夹击题,忽略外侧配合。" + ], + "katagoEraJudgement": "AI 更强调轻重和全局速度;双飞燕局部要服务于下一处大场或攻击目标。", + "trainingFocus": [ + "轻重判断", + "两翼选择", + "攻击方向" + ], + "patternCardIds": [ + "joseki_star_low_approach_one_space_pincer", + "joseki_star_approach_tenuki" + ], + "tags": [ + "星位", + "双飞燕", + "挂角", + "轻重", + "定式" + ] + }, + { + "id": "joseki_komoku_magic_sword_avoidance", + "title": "小目妖刀:避战与反击", + "family": "komoku_magic_sword", + "phase": [ + "opening", + "middlegame" + ], + "levels": [ + "advanced", + "dan" + ], + "sourceKind": "common-pattern", + "relativeSequence": [ + "Q4", + "O3", + "R6", + "Q6", + "P4" + ], + "normalizedFeatures": [ + "corner", + "3-4", + "小目", + "妖刀", + "接触战", + "定式" + ], + "branches": [ + { + "name": "避战与反击", + "sequence": "Q4 - O3 - R6 - Q6 - P4", + "whenToChoose": "对方用强硬接触手制造混战,而己方外侧有支援或能简明处理时。", + "warning": "妖刀变化诱人,但读不清时应优先选择简明安定。" + } + ], + "decisionRules": [ + "先判断是否必须战斗,再决定进入妖刀主线还是简化。", + "己方外侧薄弱时,不要主动给对方制造追击目标。", + "若 KataGo 候选点偏向稳健补强,说明本局复杂变化未必便宜。" + ], + "commonMistakes": [ + "为了下出名定式而忽略自身弱棋。", + "只读局部吃子,没有看到全局被攻。" + ], + "katagoEraJudgement": "妖刀在现代讲解中更适合作为读力和风险管理案例;实战以局面适配为先。", + "trainingFocus": [ + "风险管理", + "弱棋处理", + "复杂战斗取舍" + ], + "patternCardIds": [ + "joseki_34_high_approach_one_space_low_pincer", + "joseki_34_low_approach_attach_extend" + ], + "tags": [ + "小目", + "妖刀", + "复杂定式", + "接触战", + "定式" + ] + } + ], + "lifeDeathProblems": [ + { + "id": "life_death_true_false_eye_vital_point", + "title": "真眼假眼:急所第一感", + "difficulty": "basic", + "region": "center", + "toPlay": "B", + "objective": "判断能否做出两个真眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + } + ], + "correctMoves": [ + { + "move": "E2", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "Q15", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到真眼假眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "真眼假眼先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "真眼", + "假眼", + "眼形" + ] + }, + { + "id": "life_death_true_false_eye_wrong_order", + "title": "真眼假眼:次序错误", + "difficulty": "basic", + "region": "center", + "toPlay": "B", + "objective": "判断能否做出两个真眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + } + ], + "correctMoves": [ + { + "move": "Q16", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "R15", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到真眼假眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "真眼假眼先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "真眼", + "假眼", + "眼形" + ] + }, + { + "id": "life_death_true_false_eye_ko_branch", + "title": "真眼假眼:劫争分支", + "difficulty": "standard", + "region": "center", + "toPlay": "B", + "objective": "判断能否做出两个真眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + } + ], + "correctMoves": [ + { + "move": "R17", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "P16", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到真眼假眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "真眼假眼先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "真眼", + "假眼", + "眼形" + ] + }, + { + "id": "life_death_true_false_eye_semeai_branch", + "title": "真眼假眼:对杀分支", + "difficulty": "standard", + "region": "center", + "toPlay": "B", + "objective": "判断能否做出两个真眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + } + ], + "correctMoves": [ + { + "move": "Q17", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "D16", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到真眼假眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "真眼假眼先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "真眼", + "假眼", + "眼形" + ] + }, + { + "id": "life_death_true_false_eye_defense_branch", + "title": "真眼假眼:做活分支", + "difficulty": "advanced", + "region": "center", + "toPlay": "B", + "objective": "判断能否做出两个真眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + } + ], + "correctMoves": [ + { + "move": "R16", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "C17", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到真眼假眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "真眼假眼先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "真眼", + "假眼", + "眼形" + ] + }, + { + "id": "life_death_straight_three_vital_point", + "title": "直三:急所第一感", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "先占中点做活或杀棋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + } + ], + "correctMoves": [ + { + "move": "P17", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "C16", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到直三,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "直三先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "直三", + "眼位", + "急所" + ] + }, + { + "id": "life_death_straight_three_wrong_order", + "title": "直三:次序错误", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "先占中点做活或杀棋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + } + ], + "correctMoves": [ + { + "move": "Q15", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "D17", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到直三,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "直三先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "直三", + "眼位", + "急所" + ] + }, + { + "id": "life_death_straight_three_ko_branch", + "title": "直三:劫争分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "先占中点做活或杀棋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + } + ], + "correctMoves": [ + { + "move": "R15", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "E17", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到直三,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "直三先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "直三", + "眼位", + "急所" + ] + }, + { + "id": "life_death_straight_three_semeai_branch", + "title": "直三:对杀分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "先占中点做活或杀棋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + } + ], + "correctMoves": [ + { + "move": "P16", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "C15", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到直三,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "直三先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "直三", + "眼位", + "急所" + ] + }, + { + "id": "life_death_straight_three_defense_branch", + "title": "直三:做活分支", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "先占中点做活或杀棋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + } + ], + "correctMoves": [ + { + "move": "D16", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "E16", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到直三,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "直三先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "直三", + "眼位", + "急所" + ] + }, + { + "id": "life_death_bent_three_vital_point", + "title": "弯三:急所第一感", + "difficulty": "basic", + "region": "corner", + "toPlay": "B", + "objective": "找弯曲处的眼形急所", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + } + ], + "correctMoves": [ + { + "move": "C17", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "K10", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到弯三,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "弯三先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "弯三", + "角部", + "急所" + ] + }, + { + "id": "life_death_bent_three_wrong_order", + "title": "弯三:次序错误", + "difficulty": "basic", + "region": "corner", + "toPlay": "B", + "objective": "找弯曲处的眼形急所", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + } + ], + "correctMoves": [ + { + "move": "C16", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "K11", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到弯三,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "弯三先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "弯三", + "角部", + "急所" + ] + }, + { + "id": "life_death_bent_three_ko_branch", + "title": "弯三:劫争分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "找弯曲处的眼形急所", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + } + ], + "correctMoves": [ + { + "move": "D17", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "J10", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到弯三,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "弯三先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "弯三", + "角部", + "急所" + ] + }, + { + "id": "life_death_bent_three_semeai_branch", + "title": "弯三:对杀分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "找弯曲处的眼形急所", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + } + ], + "correctMoves": [ + { + "move": "E17", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "J11", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到弯三,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "弯三先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "弯三", + "角部", + "急所" + ] + }, + { + "id": "life_death_bent_three_defense_branch", + "title": "弯三:做活分支", + "difficulty": "advanced", + "region": "corner", + "toPlay": "B", + "objective": "找弯曲处的眼形急所", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + } + ], + "correctMoves": [ + { + "move": "C15", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "L10", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到弯三,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "弯三先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "弯三", + "角部", + "急所" + ] + }, + { + "id": "life_death_bulky_five_vital_point", + "title": "刀把五:急所第一感", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "点中间破眼或做活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + } + ], + "correctMoves": [ + { + "move": "E16", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "L11", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到刀把五,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "刀把五先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "刀把五", + "中点", + "死活" + ] + }, + { + "id": "life_death_bulky_five_wrong_order", + "title": "刀把五:次序错误", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "点中间破眼或做活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + } + ], + "correctMoves": [ + { + "move": "K10", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "D4", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到刀把五,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "刀把五先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "刀把五", + "中点", + "死活" + ] + }, + { + "id": "life_death_bulky_five_ko_branch", + "title": "刀把五:劫争分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "点中间破眼或做活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + } + ], + "correctMoves": [ + { + "move": "K11", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "C3", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到刀把五,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "刀把五先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "刀把五", + "中点", + "死活" + ] + }, + { + "id": "life_death_bulky_five_semeai_branch", + "title": "刀把五:对杀分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "点中间破眼或做活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + } + ], + "correctMoves": [ + { + "move": "J10", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "C4", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到刀把五,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "刀把五先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "刀把五", + "中点", + "死活" + ] + }, + { + "id": "life_death_bulky_five_defense_branch", + "title": "刀把五:做活分支", + "difficulty": "advanced", + "region": "corner", + "toPlay": "W", + "objective": "点中间破眼或做活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + } + ], + "correctMoves": [ + { + "move": "J11", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "D3", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到刀把五,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "刀把五先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "刀把五", + "中点", + "死活" + ] + }, + { + "id": "life_death_rectangular_six_vital_point", + "title": "方六长六:急所第一感", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "分辨方六死活边界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + } + ], + "correctMoves": [ + { + "move": "L10", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "E3", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到方六长六,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "方六长六先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "方六", + "长六", + "眼形" + ] + }, + { + "id": "life_death_rectangular_six_wrong_order", + "title": "方六长六:次序错误", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "分辨方六死活边界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + } + ], + "correctMoves": [ + { + "move": "L11", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "C5", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到方六长六,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "方六长六先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "方六", + "长六", + "眼形" + ] + }, + { + "id": "life_death_rectangular_six_ko_branch", + "title": "方六长六:劫争分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "分辨方六死活边界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + } + ], + "correctMoves": [ + { + "move": "D4", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "F4", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到方六长六,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "方六长六先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "方六", + "长六", + "眼形" + ] + }, + { + "id": "life_death_rectangular_six_semeai_branch", + "title": "方六长六:对杀分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "分辨方六死活边界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + } + ], + "correctMoves": [ + { + "move": "C3", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "E5", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到方六长六,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "方六长六先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "方六", + "长六", + "眼形" + ] + }, + { + "id": "life_death_rectangular_six_defense_branch", + "title": "方六长六:做活分支", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "分辨方六死活边界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + } + ], + "correctMoves": [ + { + "move": "C4", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "B3", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到方六长六,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "方六长六先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "方六", + "长六", + "眼形" + ] + }, + { + "id": "life_death_carpenter_square_vital_point", + "title": "角部曲四:急所第一感", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "判断劫活和死棋分界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + } + ], + "correctMoves": [ + { + "move": "D3", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "B4", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到角部曲四,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "角部曲四先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "曲四", + "角部", + "劫活" + ] + }, + { + "id": "life_death_carpenter_square_wrong_order", + "title": "角部曲四:次序错误", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "判断劫活和死棋分界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + } + ], + "correctMoves": [ + { + "move": "E3", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "D2", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到角部曲四,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "角部曲四先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "曲四", + "角部", + "劫活" + ] + }, + { + "id": "life_death_carpenter_square_ko_branch", + "title": "角部曲四:劫争分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "判断劫活和死棋分界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + } + ], + "correctMoves": [ + { + "move": "C5", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "E2", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到角部曲四,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "角部曲四先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "曲四", + "角部", + "劫活" + ] + }, + { + "id": "life_death_carpenter_square_semeai_branch", + "title": "角部曲四:对杀分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "判断劫活和死棋分界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + } + ], + "correctMoves": [ + { + "move": "F4", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "Q16", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到角部曲四,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "角部曲四先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "曲四", + "角部", + "劫活" + ] + }, + { + "id": "life_death_carpenter_square_defense_branch", + "title": "角部曲四:做活分支", + "difficulty": "advanced", + "region": "corner", + "toPlay": "W", + "objective": "判断劫活和死棋分界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + } + ], + "correctMoves": [ + { + "move": "E5", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "R17", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到角部曲四,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "角部曲四先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "曲四", + "角部", + "劫活" + ] + }, + { + "id": "life_death_l_group_vital_point", + "title": "角部 L 形:急所第一感", + "difficulty": "basic", + "region": "corner", + "toPlay": "B", + "objective": "识别 L 形基本死活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + } + ], + "correctMoves": [ + { + "move": "B3", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "Q17", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到角部 L 形,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "角部 L 形先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "L形", + "角部", + "死活" + ] + }, + { + "id": "life_death_l_group_wrong_order", + "title": "角部 L 形:次序错误", + "difficulty": "basic", + "region": "corner", + "toPlay": "B", + "objective": "识别 L 形基本死活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + } + ], + "correctMoves": [ + { + "move": "B4", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "R16", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到角部 L 形,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "角部 L 形先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "L形", + "角部", + "死活" + ] + }, + { + "id": "life_death_l_group_ko_branch", + "title": "角部 L 形:劫争分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "识别 L 形基本死活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + } + ], + "correctMoves": [ + { + "move": "D2", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "P17", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到角部 L 形,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "角部 L 形先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "L形", + "角部", + "死活" + ] + }, + { + "id": "life_death_l_group_semeai_branch", + "title": "角部 L 形:对杀分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "识别 L 形基本死活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + } + ], + "correctMoves": [ + { + "move": "E2", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "Q15", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到角部 L 形,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "角部 L 形先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "L形", + "角部", + "死活" + ] + }, + { + "id": "life_death_l_group_defense_branch", + "title": "角部 L 形:做活分支", + "difficulty": "advanced", + "region": "corner", + "toPlay": "B", + "objective": "识别 L 形基本死活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + } + ], + "correctMoves": [ + { + "move": "Q16", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "R15", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到角部 L 形,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "角部 L 形先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "L形", + "角部", + "死活" + ] + }, + { + "id": "life_death_seki_shape_vital_point", + "title": "双活:急所第一感", + "difficulty": "basic", + "region": "side", + "toPlay": "W", + "objective": "避免把双活走成自损", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + } + ], + "correctMoves": [ + { + "move": "R17", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "P16", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到双活,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "双活先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "双活", + "气", + "眼" + ] + }, + { + "id": "life_death_seki_shape_wrong_order", + "title": "双活:次序错误", + "difficulty": "basic", + "region": "side", + "toPlay": "W", + "objective": "避免把双活走成自损", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + } + ], + "correctMoves": [ + { + "move": "Q17", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "D16", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到双活,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "双活先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "双活", + "气", + "眼" + ] + }, + { + "id": "life_death_seki_shape_ko_branch", + "title": "双活:劫争分支", + "difficulty": "standard", + "region": "side", + "toPlay": "W", + "objective": "避免把双活走成自损", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + } + ], + "correctMoves": [ + { + "move": "R16", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "C17", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到双活,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "双活先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "双活", + "气", + "眼" + ] + }, + { + "id": "life_death_seki_shape_semeai_branch", + "title": "双活:对杀分支", + "difficulty": "standard", + "region": "side", + "toPlay": "W", + "objective": "避免把双活走成自损", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + } + ], + "correctMoves": [ + { + "move": "P17", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "C16", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到双活,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "双活先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "双活", + "气", + "眼" + ] + }, + { + "id": "life_death_seki_shape_defense_branch", + "title": "双活:做活分支", + "difficulty": "advanced", + "region": "side", + "toPlay": "W", + "objective": "避免把双活走成自损", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + } + ], + "correctMoves": [ + { + "move": "Q15", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "D17", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到双活,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "双活先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "双活", + "气", + "眼" + ] + }, + { + "id": "life_death_ko_life_vital_point", + "title": "劫活:急所第一感", + "difficulty": "basic", + "region": "corner", + "toPlay": "B", + "objective": "判断是否需要先找劫材", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + } + ], + "correctMoves": [ + { + "move": "R15", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "E17", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到劫活,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "劫活先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "劫", + "劫材", + "角部" + ] + }, + { + "id": "life_death_ko_life_wrong_order", + "title": "劫活:次序错误", + "difficulty": "basic", + "region": "corner", + "toPlay": "B", + "objective": "判断是否需要先找劫材", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + } + ], + "correctMoves": [ + { + "move": "P16", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "C15", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到劫活,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "劫活先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "劫", + "劫材", + "角部" + ] + }, + { + "id": "life_death_ko_life_ko_branch", + "title": "劫活:劫争分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "判断是否需要先找劫材", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + } + ], + "correctMoves": [ + { + "move": "D16", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "E16", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到劫活,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "劫活先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "劫", + "劫材", + "角部" + ] + }, + { + "id": "life_death_ko_life_semeai_branch", + "title": "劫活:对杀分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "判断是否需要先找劫材", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + } + ], + "correctMoves": [ + { + "move": "C17", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "K10", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到劫活,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "劫活先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "劫", + "劫材", + "角部" + ] + }, + { + "id": "life_death_ko_life_defense_branch", + "title": "劫活:做活分支", + "difficulty": "advanced", + "region": "corner", + "toPlay": "B", + "objective": "判断是否需要先找劫材", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + } + ], + "correctMoves": [ + { + "move": "C16", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "K11", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到劫活,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "劫活先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "劫", + "劫材", + "角部" + ] + }, + { + "id": "life_death_eye_vs_no_eye_vital_point", + "title": "有眼杀无眼:急所第一感", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "对杀前先比较眼和气", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + } + ], + "correctMoves": [ + { + "move": "D17", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "J10", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到有眼杀无眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "有眼杀无眼先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "对杀", + "有眼杀无眼", + "气" + ] + }, + { + "id": "life_death_eye_vs_no_eye_wrong_order", + "title": "有眼杀无眼:次序错误", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "对杀前先比较眼和气", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + } + ], + "correctMoves": [ + { + "move": "E17", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "J11", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到有眼杀无眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "有眼杀无眼先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "对杀", + "有眼杀无眼", + "气" + ] + }, + { + "id": "life_death_eye_vs_no_eye_ko_branch", + "title": "有眼杀无眼:劫争分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "对杀前先比较眼和气", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + } + ], + "correctMoves": [ + { + "move": "C15", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "L10", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到有眼杀无眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "有眼杀无眼先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "对杀", + "有眼杀无眼", + "气" + ] + }, + { + "id": "life_death_eye_vs_no_eye_semeai_branch", + "title": "有眼杀无眼:对杀分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "对杀前先比较眼和气", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + } + ], + "correctMoves": [ + { + "move": "E16", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "L11", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到有眼杀无眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "有眼杀无眼先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "对杀", + "有眼杀无眼", + "气" + ] + }, + { + "id": "life_death_eye_vs_no_eye_defense_branch", + "title": "有眼杀无眼:做活分支", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "对杀前先比较眼和气", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + } + ], + "correctMoves": [ + { + "move": "K10", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "D4", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到有眼杀无眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "有眼杀无眼先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "对杀", + "有眼杀无眼", + "气" + ] + }, + { + "id": "life_death_liberty_race_vital_point", + "title": "对杀紧气:急所第一感", + "difficulty": "basic", + "region": "center", + "toPlay": "W", + "objective": "用先后手紧气赢对杀", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + } + ], + "correctMoves": [ + { + "move": "K11", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "C3", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到对杀紧气,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "对杀紧气先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "对杀", + "紧气", + "手筋" + ] + }, + { + "id": "life_death_liberty_race_wrong_order", + "title": "对杀紧气:次序错误", + "difficulty": "basic", + "region": "center", + "toPlay": "W", + "objective": "用先后手紧气赢对杀", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + } + ], + "correctMoves": [ + { + "move": "J10", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "C4", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到对杀紧气,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "对杀紧气先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "对杀", + "紧气", + "手筋" + ] + }, + { + "id": "life_death_liberty_race_ko_branch", + "title": "对杀紧气:劫争分支", + "difficulty": "standard", + "region": "center", + "toPlay": "W", + "objective": "用先后手紧气赢对杀", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + } + ], + "correctMoves": [ + { + "move": "J11", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "D3", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到对杀紧气,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "对杀紧气先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "对杀", + "紧气", + "手筋" + ] + }, + { + "id": "life_death_liberty_race_semeai_branch", + "title": "对杀紧气:对杀分支", + "difficulty": "standard", + "region": "center", + "toPlay": "W", + "objective": "用先后手紧气赢对杀", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + } + ], + "correctMoves": [ + { + "move": "L10", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "E3", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到对杀紧气,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "对杀紧气先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "对杀", + "紧气", + "手筋" + ] + }, + { + "id": "life_death_liberty_race_defense_branch", + "title": "对杀紧气:做活分支", + "difficulty": "advanced", + "region": "center", + "toPlay": "W", + "objective": "用先后手紧气赢对杀", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + } + ], + "correctMoves": [ + { + "move": "L11", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "C5", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到对杀紧气,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "对杀紧气先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "对杀", + "紧气", + "手筋" + ] + }, + { + "id": "life_death_under_stones_vital_point", + "title": "石下手筋:急所第一感", + "difficulty": "basic", + "region": "corner", + "toPlay": "B", + "objective": "通过弃子获得倒脱靴", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + } + ], + "correctMoves": [ + { + "move": "D4", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "F4", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到石下手筋,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "石下手筋先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "石下", + "弃子", + "手筋" + ] + }, + { + "id": "life_death_under_stones_wrong_order", + "title": "石下手筋:次序错误", + "difficulty": "basic", + "region": "corner", + "toPlay": "B", + "objective": "通过弃子获得倒脱靴", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + } + ], + "correctMoves": [ + { + "move": "C3", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "E5", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到石下手筋,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "石下手筋先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "石下", + "弃子", + "手筋" + ] + }, + { + "id": "life_death_under_stones_ko_branch", + "title": "石下手筋:劫争分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "通过弃子获得倒脱靴", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + } + ], + "correctMoves": [ + { + "move": "C4", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "B3", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到石下手筋,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "石下手筋先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "石下", + "弃子", + "手筋" + ] + }, + { + "id": "life_death_under_stones_semeai_branch", + "title": "石下手筋:对杀分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "通过弃子获得倒脱靴", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + } + ], + "correctMoves": [ + { + "move": "D3", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "B4", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到石下手筋,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "石下手筋先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "石下", + "弃子", + "手筋" + ] + }, + { + "id": "life_death_under_stones_defense_branch", + "title": "石下手筋:做活分支", + "difficulty": "advanced", + "region": "corner", + "toPlay": "B", + "objective": "通过弃子获得倒脱靴", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + } + ], + "correctMoves": [ + { + "move": "E3", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "D2", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到石下手筋,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "石下手筋先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "石下", + "弃子", + "手筋" + ] + }, + { + "id": "life_death_corner_throw_in_vital_point", + "title": "角部扑入:急所第一感", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "用扑入破眼或造劫", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + } + ], + "correctMoves": [ + { + "move": "C5", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "E2", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到角部扑入,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "角部扑入先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "扑入", + "角部", + "破眼" + ] + }, + { + "id": "life_death_corner_throw_in_wrong_order", + "title": "角部扑入:次序错误", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "用扑入破眼或造劫", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + } + ], + "correctMoves": [ + { + "move": "F4", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "Q16", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到角部扑入,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "角部扑入先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "扑入", + "角部", + "破眼" + ] + }, + { + "id": "life_death_corner_throw_in_ko_branch", + "title": "角部扑入:劫争分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "用扑入破眼或造劫", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + } + ], + "correctMoves": [ + { + "move": "E5", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "R17", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到角部扑入,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "角部扑入先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "扑入", + "角部", + "破眼" + ] + }, + { + "id": "life_death_corner_throw_in_semeai_branch", + "title": "角部扑入:对杀分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "用扑入破眼或造劫", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + } + ], + "correctMoves": [ + { + "move": "B3", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "Q17", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到角部扑入,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "角部扑入先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "扑入", + "角部", + "破眼" + ] + }, + { + "id": "life_death_corner_throw_in_defense_branch", + "title": "角部扑入:做活分支", + "difficulty": "advanced", + "region": "corner", + "toPlay": "W", + "objective": "用扑入破眼或造劫", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + } + ], + "correctMoves": [ + { + "move": "B4", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "R16", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到角部扑入,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "角部扑入先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "扑入", + "角部", + "破眼" + ] + }, + { + "id": "life_death_second_line_hane_vital_point", + "title": "二路扳:急所第一感", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "缩小眼位或争先手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + } + ], + "correctMoves": [ + { + "move": "D2", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "P17", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到二路扳,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "二路扳先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "二路", + "扳", + "眼形" + ] + }, + { + "id": "life_death_second_line_hane_wrong_order", + "title": "二路扳:次序错误", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "缩小眼位或争先手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + } + ], + "correctMoves": [ + { + "move": "E2", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "Q15", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到二路扳,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "二路扳先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "二路", + "扳", + "眼形" + ] + }, + { + "id": "life_death_second_line_hane_ko_branch", + "title": "二路扳:劫争分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "缩小眼位或争先手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + } + ], + "correctMoves": [ + { + "move": "Q16", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "R15", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到二路扳,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "二路扳先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "二路", + "扳", + "眼形" + ] + }, + { + "id": "life_death_second_line_hane_semeai_branch", + "title": "二路扳:对杀分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "缩小眼位或争先手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + } + ], + "correctMoves": [ + { + "move": "R17", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "P16", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到二路扳,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "二路扳先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "二路", + "扳", + "眼形" + ] + }, + { + "id": "life_death_second_line_hane_defense_branch", + "title": "二路扳:做活分支", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "缩小眼位或争先手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + } + ], + "correctMoves": [ + { + "move": "Q17", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "D16", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到二路扳,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "二路扳先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "二路", + "扳", + "眼形" + ] + }, + { + "id": "life_death_shortage_liberties_vital_point", + "title": "气紧缺陷:急所第一感", + "difficulty": "basic", + "region": "center", + "toPlay": "W", + "objective": "利用对方气紧制造死活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + } + ], + "correctMoves": [ + { + "move": "R16", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "C17", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到气紧缺陷,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "气紧缺陷先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "气紧", + "缺陷", + "手筋" + ] + }, + { + "id": "life_death_shortage_liberties_wrong_order", + "title": "气紧缺陷:次序错误", + "difficulty": "basic", + "region": "center", + "toPlay": "W", + "objective": "利用对方气紧制造死活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + } + ], + "correctMoves": [ + { + "move": "P17", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "C16", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到气紧缺陷,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "气紧缺陷先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "气紧", + "缺陷", + "手筋" + ] + }, + { + "id": "life_death_shortage_liberties_ko_branch", + "title": "气紧缺陷:劫争分支", + "difficulty": "standard", + "region": "center", + "toPlay": "W", + "objective": "利用对方气紧制造死活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + } + ], + "correctMoves": [ + { + "move": "Q15", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "D17", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到气紧缺陷,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "气紧缺陷先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "气紧", + "缺陷", + "手筋" + ] + }, + { + "id": "life_death_shortage_liberties_semeai_branch", + "title": "气紧缺陷:对杀分支", + "difficulty": "standard", + "region": "center", + "toPlay": "W", + "objective": "利用对方气紧制造死活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + } + ], + "correctMoves": [ + { + "move": "R15", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "E17", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到气紧缺陷,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "气紧缺陷先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "气紧", + "缺陷", + "手筋" + ] + }, + { + "id": "life_death_shortage_liberties_defense_branch", + "title": "气紧缺陷:做活分支", + "difficulty": "advanced", + "region": "center", + "toPlay": "W", + "objective": "利用对方气紧制造死活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + } + ], + "correctMoves": [ + { + "move": "P16", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "C15", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到气紧缺陷,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "气紧缺陷先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "气紧", + "缺陷", + "手筋" + ] + }, + { + "id": "life_death_snapback_eye_vital_point", + "title": "倒扑破眼:急所第一感", + "difficulty": "basic", + "region": "corner", + "toPlay": "B", + "objective": "先送一子再吃回破眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + } + ], + "correctMoves": [ + { + "move": "D16", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "E16", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到倒扑破眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "倒扑破眼先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "倒扑", + "破眼", + "吃回" + ] + }, + { + "id": "life_death_snapback_eye_wrong_order", + "title": "倒扑破眼:次序错误", + "difficulty": "basic", + "region": "corner", + "toPlay": "B", + "objective": "先送一子再吃回破眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + } + ], + "correctMoves": [ + { + "move": "C17", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "K10", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到倒扑破眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "倒扑破眼先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "倒扑", + "破眼", + "吃回" + ] + }, + { + "id": "life_death_snapback_eye_ko_branch", + "title": "倒扑破眼:劫争分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "先送一子再吃回破眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + } + ], + "correctMoves": [ + { + "move": "C16", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "K11", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到倒扑破眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "倒扑破眼先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "倒扑", + "破眼", + "吃回" + ] + }, + { + "id": "life_death_snapback_eye_semeai_branch", + "title": "倒扑破眼:对杀分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "先送一子再吃回破眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + } + ], + "correctMoves": [ + { + "move": "D17", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "J10", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到倒扑破眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "倒扑破眼先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "倒扑", + "破眼", + "吃回" + ] + }, + { + "id": "life_death_snapback_eye_defense_branch", + "title": "倒扑破眼:做活分支", + "difficulty": "advanced", + "region": "corner", + "toPlay": "B", + "objective": "先送一子再吃回破眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + } + ], + "correctMoves": [ + { + "move": "E17", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "J11", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到倒扑破眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "倒扑破眼先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "倒扑", + "破眼", + "吃回" + ] + }, + { + "id": "life_death_nakade_four_vital_point", + "title": "中手四:急所第一感", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "判断四目眼位的中手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + } + ], + "correctMoves": [ + { + "move": "C15", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "L10", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到中手四,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "中手四先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "中手", + "四目", + "急所" + ] + }, + { + "id": "life_death_nakade_four_wrong_order", + "title": "中手四:次序错误", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "判断四目眼位的中手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + } + ], + "correctMoves": [ + { + "move": "E16", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "L11", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到中手四,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "中手四先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "中手", + "四目", + "急所" + ] + }, + { + "id": "life_death_nakade_four_ko_branch", + "title": "中手四:劫争分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "判断四目眼位的中手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + } + ], + "correctMoves": [ + { + "move": "K10", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "D4", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到中手四,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "中手四先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "中手", + "四目", + "急所" + ] + }, + { + "id": "life_death_nakade_four_semeai_branch", + "title": "中手四:对杀分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "判断四目眼位的中手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + } + ], + "correctMoves": [ + { + "move": "K11", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "C3", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到中手四,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "中手四先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "中手", + "四目", + "急所" + ] + }, + { + "id": "life_death_nakade_four_defense_branch", + "title": "中手四:做活分支", + "difficulty": "advanced", + "region": "corner", + "toPlay": "W", + "objective": "判断四目眼位的中手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + } + ], + "correctMoves": [ + { + "move": "J10", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "C4", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到中手四,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "中手四先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "中手", + "四目", + "急所" + ] + }, + { + "id": "life_death_nakade_five_vital_point", + "title": "中手五:急所第一感", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "找五目眼位的中点", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + } + ], + "correctMoves": [ + { + "move": "J11", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "D3", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到中手五,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "中手五先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "中手", + "五目", + "急所" + ] + }, + { + "id": "life_death_nakade_five_wrong_order", + "title": "中手五:次序错误", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "找五目眼位的中点", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + } + ], + "correctMoves": [ + { + "move": "L10", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "E3", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到中手五,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "中手五先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "中手", + "五目", + "急所" + ] + }, + { + "id": "life_death_nakade_five_ko_branch", + "title": "中手五:劫争分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "找五目眼位的中点", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + } + ], + "correctMoves": [ + { + "move": "L11", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "C5", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到中手五,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "中手五先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "中手", + "五目", + "急所" + ] + }, + { + "id": "life_death_nakade_five_semeai_branch", + "title": "中手五:对杀分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "找五目眼位的中点", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + } + ], + "correctMoves": [ + { + "move": "D4", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "F4", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到中手五,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "中手五先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "中手", + "五目", + "急所" + ] + }, + { + "id": "life_death_nakade_five_defense_branch", + "title": "中手五:做活分支", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "找五目眼位的中点", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + } + ], + "correctMoves": [ + { + "move": "C3", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "E5", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到中手五,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "中手五先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "中手", + "五目", + "急所" + ] + }, + { + "id": "life_death_bent_four_corner_vital_point", + "title": "角上盘角曲四:急所第一感", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "区分无条件死和劫争", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + } + ], + "correctMoves": [ + { + "move": "C4", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "B3", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到角上盘角曲四,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "角上盘角曲四先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "盘角曲四", + "角部", + "劫" + ] + }, + { + "id": "life_death_bent_four_corner_wrong_order", + "title": "角上盘角曲四:次序错误", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "区分无条件死和劫争", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + } + ], + "correctMoves": [ + { + "move": "D3", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "B4", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到角上盘角曲四,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "角上盘角曲四先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "盘角曲四", + "角部", + "劫" + ] + }, + { + "id": "life_death_bent_four_corner_ko_branch", + "title": "角上盘角曲四:劫争分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "区分无条件死和劫争", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + } + ], + "correctMoves": [ + { + "move": "E3", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "D2", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到角上盘角曲四,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "角上盘角曲四先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "盘角曲四", + "角部", + "劫" + ] + }, + { + "id": "life_death_bent_four_corner_semeai_branch", + "title": "角上盘角曲四:对杀分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "区分无条件死和劫争", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + } + ], + "correctMoves": [ + { + "move": "C5", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "E2", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到角上盘角曲四,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "角上盘角曲四先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "盘角曲四", + "角部", + "劫" + ] + }, + { + "id": "life_death_bent_four_corner_defense_branch", + "title": "角上盘角曲四:做活分支", + "difficulty": "advanced", + "region": "corner", + "toPlay": "W", + "objective": "区分无条件死和劫争", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + } + ], + "correctMoves": [ + { + "move": "F4", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "Q16", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到角上盘角曲四,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "角上盘角曲四先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "盘角曲四", + "角部", + "劫" + ] + }, + { + "id": "life_death_large_eye_reduce_vital_point", + "title": "大眼位缩小:急所第一感", + "difficulty": "basic", + "region": "center", + "toPlay": "B", + "objective": "先缩眼再点眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + } + ], + "correctMoves": [ + { + "move": "E5", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "R17", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到大眼位缩小,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "大眼位缩小先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "大眼", + "缩眼", + "次序" + ] + }, + { + "id": "life_death_large_eye_reduce_wrong_order", + "title": "大眼位缩小:次序错误", + "difficulty": "basic", + "region": "center", + "toPlay": "B", + "objective": "先缩眼再点眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + } + ], + "correctMoves": [ + { + "move": "B3", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "Q17", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到大眼位缩小,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "大眼位缩小先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "大眼", + "缩眼", + "次序" + ] + }, + { + "id": "life_death_large_eye_reduce_ko_branch", + "title": "大眼位缩小:劫争分支", + "difficulty": "standard", + "region": "center", + "toPlay": "B", + "objective": "先缩眼再点眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + } + ], + "correctMoves": [ + { + "move": "B4", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "R16", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到大眼位缩小,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "大眼位缩小先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "大眼", + "缩眼", + "次序" + ] + }, + { + "id": "life_death_large_eye_reduce_semeai_branch", + "title": "大眼位缩小:对杀分支", + "difficulty": "standard", + "region": "center", + "toPlay": "B", + "objective": "先缩眼再点眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + } + ], + "correctMoves": [ + { + "move": "D2", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "P17", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到大眼位缩小,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "大眼位缩小先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "大眼", + "缩眼", + "次序" + ] + }, + { + "id": "life_death_large_eye_reduce_defense_branch", + "title": "大眼位缩小:做活分支", + "difficulty": "advanced", + "region": "center", + "toPlay": "B", + "objective": "先缩眼再点眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + } + ], + "correctMoves": [ + { + "move": "E2", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "Q15", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到大眼位缩小,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "大眼位缩小先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "大眼", + "缩眼", + "次序" + ] + }, + { + "id": "life_death_connect_or_die_vital_point", + "title": "连接做活:急所第一感", + "difficulty": "basic", + "region": "side", + "toPlay": "W", + "objective": "分辨连回和就地做活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + } + ], + "correctMoves": [ + { + "move": "Q16", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "R15", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到连接做活,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "连接做活先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "连接", + "做活", + "薄棋" + ] + }, + { + "id": "life_death_connect_or_die_wrong_order", + "title": "连接做活:次序错误", + "difficulty": "basic", + "region": "side", + "toPlay": "W", + "objective": "分辨连回和就地做活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + } + ], + "correctMoves": [ + { + "move": "R17", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "P16", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到连接做活,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "连接做活先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "连接", + "做活", + "薄棋" + ] + }, + { + "id": "life_death_connect_or_die_ko_branch", + "title": "连接做活:劫争分支", + "difficulty": "standard", + "region": "side", + "toPlay": "W", + "objective": "分辨连回和就地做活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + } + ], + "correctMoves": [ + { + "move": "Q17", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "D16", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到连接做活,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "连接做活先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "连接", + "做活", + "薄棋" + ] + }, + { + "id": "life_death_connect_or_die_semeai_branch", + "title": "连接做活:对杀分支", + "difficulty": "standard", + "region": "side", + "toPlay": "W", + "objective": "分辨连回和就地做活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + } + ], + "correctMoves": [ + { + "move": "R16", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "C17", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到连接做活,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "连接做活先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "连接", + "做活", + "薄棋" + ] + }, + { + "id": "life_death_connect_or_die_defense_branch", + "title": "连接做活:做活分支", + "difficulty": "advanced", + "region": "side", + "toPlay": "W", + "objective": "分辨连回和就地做活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + } + ], + "correctMoves": [ + { + "move": "P17", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "C16", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到连接做活,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "连接做活先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "连接", + "做活", + "薄棋" + ] + }, + { + "id": "life_death_hane_first_line_vital_point", + "title": "一路扳渡:急所第一感", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "边上一路扳渡的生死边界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + } + ], + "correctMoves": [ + { + "move": "Q15", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "D17", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到一路扳渡,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "一路扳渡先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "一路", + "扳渡", + "边上" + ] + }, + { + "id": "life_death_hane_first_line_wrong_order", + "title": "一路扳渡:次序错误", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "边上一路扳渡的生死边界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + } + ], + "correctMoves": [ + { + "move": "R15", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "E17", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到一路扳渡,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "一路扳渡先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "一路", + "扳渡", + "边上" + ] + }, + { + "id": "life_death_hane_first_line_ko_branch", + "title": "一路扳渡:劫争分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "边上一路扳渡的生死边界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + } + ], + "correctMoves": [ + { + "move": "P16", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "C15", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到一路扳渡,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "一路扳渡先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "一路", + "扳渡", + "边上" + ] + }, + { + "id": "life_death_hane_first_line_semeai_branch", + "title": "一路扳渡:对杀分支", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "边上一路扳渡的生死边界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + } + ], + "correctMoves": [ + { + "move": "D16", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "E16", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到一路扳渡,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "一路扳渡先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "一路", + "扳渡", + "边上" + ] + }, + { + "id": "life_death_hane_first_line_defense_branch", + "title": "一路扳渡:做活分支", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "边上一路扳渡的生死边界", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + } + ], + "correctMoves": [ + { + "move": "C17", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "K10", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到一路扳渡,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "一路扳渡先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "一路", + "扳渡", + "边上" + ] + }, + { + "id": "life_death_throw_in_ko_vital_point", + "title": "扑入造劫:急所第一感", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "用扑入制造劫活资源", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + } + ], + "correctMoves": [ + { + "move": "C16", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "K11", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到扑入造劫,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "扑入造劫先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "扑入", + "造劫", + "角部" + ] + }, + { + "id": "life_death_throw_in_ko_wrong_order", + "title": "扑入造劫:次序错误", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "用扑入制造劫活资源", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + } + ], + "correctMoves": [ + { + "move": "D17", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "J10", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到扑入造劫,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "扑入造劫先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "扑入", + "造劫", + "角部" + ] + }, + { + "id": "life_death_throw_in_ko_ko_branch", + "title": "扑入造劫:劫争分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "用扑入制造劫活资源", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + } + ], + "correctMoves": [ + { + "move": "E17", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "J11", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到扑入造劫,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "扑入造劫先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "扑入", + "造劫", + "角部" + ] + }, + { + "id": "life_death_throw_in_ko_semeai_branch", + "title": "扑入造劫:对杀分支", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "用扑入制造劫活资源", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + } + ], + "correctMoves": [ + { + "move": "C15", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "L10", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到扑入造劫,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "扑入造劫先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "扑入", + "造劫", + "角部" + ] + }, + { + "id": "life_death_throw_in_ko_defense_branch", + "title": "扑入造劫:做活分支", + "difficulty": "advanced", + "region": "corner", + "toPlay": "W", + "objective": "用扑入制造劫活资源", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + } + ], + "correctMoves": [ + { + "move": "E16", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "L11", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到扑入造劫,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "扑入造劫先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "扑入", + "造劫", + "角部" + ] + }, + { + "id": "life_death_capturing_eye_shape_vital_point", + "title": "吃子做眼:急所第一感", + "difficulty": "basic", + "region": "center", + "toPlay": "B", + "objective": "通过吃子形成真眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + } + ], + "correctMoves": [ + { + "move": "K10", + "explanation": "先手方第一手直接占急所。" + } + ], + "failureMoves": [ + { + "move": "D4", + "why": "走外围补棋会让对方先占中点。" + } + ], + "teaching": { + "recognition": "看到吃子做眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "第一感先找对方眼形的中心点。", + "explanation": "先手方第一手直接占急所。", + "memoryCue": "吃子做眼先找急所,不先补外围。", + "failureExplanation": "走外围补棋会让对方先占中点。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "吃子", + "做眼", + "眼形" + ] + }, + { + "id": "life_death_capturing_eye_shape_wrong_order", + "title": "吃子做眼:次序错误", + "difficulty": "basic", + "region": "center", + "toPlay": "B", + "objective": "通过吃子形成真眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + } + ], + "correctMoves": [ + { + "move": "K11", + "explanation": "先交换再占急所,避免对方反先。" + } + ], + "failureMoves": [ + { + "move": "C3", + "why": "直接点入可能被对方反吃成活。" + } + ], + "teaching": { + "recognition": "看到吃子做眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "读清楚哪一手是先手交换。", + "explanation": "先交换再占急所,避免对方反先。", + "memoryCue": "吃子做眼先找急所,不先补外围。", + "failureExplanation": "直接点入可能被对方反吃成活。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "吃子", + "做眼", + "眼形" + ] + }, + { + "id": "life_death_capturing_eye_shape_ko_branch", + "title": "吃子做眼:劫争分支", + "difficulty": "standard", + "region": "center", + "toPlay": "B", + "objective": "通过吃子形成真眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + } + ], + "correctMoves": [ + { + "move": "J10", + "explanation": "局部不能直接杀净,需要先判断劫材。" + } + ], + "failureMoves": [ + { + "move": "C4", + "why": "忽略劫材会把可杀棋走成无理。" + } + ], + "teaching": { + "recognition": "看到吃子做眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "看到角部曲折和一只眼时先问是不是劫。", + "explanation": "局部不能直接杀净,需要先判断劫材。", + "memoryCue": "吃子做眼先找急所,不先补外围。", + "failureExplanation": "忽略劫材会把可杀棋走成无理。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "吃子", + "做眼", + "眼形" + ] + }, + { + "id": "life_death_capturing_eye_shape_semeai_branch", + "title": "吃子做眼:对杀分支", + "difficulty": "standard", + "region": "center", + "toPlay": "B", + "objective": "通过吃子形成真眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + } + ], + "correctMoves": [ + { + "move": "J11", + "explanation": "先数气,再看眼,最后看紧气手筋。" + } + ], + "failureMoves": [ + { + "move": "D3", + "why": "只数外气不数公气,结论会错。" + } + ], + "teaching": { + "recognition": "看到吃子做眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "有眼杀无眼是第一原则。", + "explanation": "先数气,再看眼,最后看紧气手筋。", + "memoryCue": "吃子做眼先找急所,不先补外围。", + "failureExplanation": "只数外气不数公气,结论会错。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "吃子", + "做眼", + "眼形" + ] + }, + { + "id": "life_death_capturing_eye_shape_defense_branch", + "title": "吃子做眼:做活分支", + "difficulty": "advanced", + "region": "center", + "toPlay": "B", + "objective": "通过吃子形成真眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + } + ], + "correctMoves": [ + { + "move": "L10", + "explanation": "先保留最大眼位,再避免被点中。" + } + ], + "failureMoves": [ + { + "move": "E3", + "why": "补在边上看似厚,实际放过中手。" + } + ], + "teaching": { + "recognition": "看到吃子做眼,先找眼形中心、断点和双方气数。", + "firstFeeling": "做活不是补最多的地方,而是补最关键的眼位。", + "explanation": "先保留最大眼位,再避免被点中。", + "memoryCue": "吃子做眼先找急所,不先补外围。", + "failureExplanation": "补在边上看似厚,实际放过中手。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_corner_carpenter_square", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "吃子", + "做眼", + "眼形" + ] + }, + { + "id": "life_death_plum_six_vital_point", + "title": "梅花六:中手急所", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "识别梅花六的眼形中心并判断杀活", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C2" + }, + { + "color": "W", + "point": "B2" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "B3" + } + ], + "correctMoves": [ + { + "move": "C2", + "explanation": "先点眼形中心,让对方两只眼不能同时成立。" + } + ], + "failureMoves": [ + { + "move": "E2", + "why": "从外面收气太慢,会让对方先补成真眼。" + } + ], + "teaching": { + "recognition": "梅花六的关键不是外形有六个点,而是中间一点能否让两眼分裂。", + "firstFeeling": "第一感先找中心急所,再读对方最大做眼点。", + "explanation": "先点眼形中心,让对方两只眼不能同时成立。", + "memoryCue": "梅花六先问中点,别先从外面收气。", + "failureExplanation": "从外面收气太慢,会让对方先补成真眼。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_rectangular_six_alive", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "梅花六", + "眼形", + "中手", + "急所" + ] + }, + { + "id": "life_death_grape_six_vital_point", + "title": "葡萄六:真假眼位", + "difficulty": "standard", + "region": "side", + "toPlay": "W", + "objective": "判断葡萄六是否能形成两个真眼", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "E4" + }, + { + "color": "W", + "point": "F3" + } + ], + "correctMoves": [ + { + "move": "E3", + "explanation": "占住眼形腰点,迫使对方眼位互相干扰。" + } + ], + "failureMoves": [ + { + "move": "C2", + "why": "只破一只眼不够,对方可以转身做出第二眼。" + } + ], + "teaching": { + "recognition": "葡萄六常有看似两眼的外形,实战要找两只眼之间的共享点。", + "firstFeeling": "先找能同时影响两只眼的腰点。", + "explanation": "占住眼形腰点,迫使对方眼位互相干扰。", + "memoryCue": "葡萄六看连接处,不只看外面像不像眼。", + "failureExplanation": "只破一只眼不够,对方可以转身做出第二眼。" + }, + "patternCardIds": [ + "life_death_true_eye_false_eye", + "life_death_rectangular_six_alive", + "life_death_bulky_five_vital_point" + ], + "tags": [ + "葡萄六", + "眼形", + "真假眼", + "做活" + ] + }, + { + "id": "life_death_board_six_alive_shape", + "title": "板六:真假活形", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "区分边上板六的真活和薄味", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C2" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "E3" + } + ], + "correctMoves": [ + { + "move": "D1", + "explanation": "占边上要点,确认两眼或迫使对方不能同时破两处。" + } + ], + "failureMoves": [ + { + "move": "F2", + "why": "从外侧补棋不能解决边上一线的眼位问题。" + } + ], + "teaching": { + "recognition": "板六要结合边线判断,不能只按中腹六目形粗略断活。", + "firstFeeling": "先看一线和二线的眼位是否互相独立。", + "explanation": "占边上要点,确认两眼或迫使对方不能同时破两处。", + "memoryCue": "板六看边线,边上多一口气也可能改变结论。", + "failureExplanation": "从外侧补棋不能解决边上一线的眼位问题。" + }, + "patternCardIds": [ + "life_death_rectangular_six_alive", + "life_death_side_second_line_hane", + "life_death_true_eye_false_eye" + ], + "tags": [ + "板六", + "边上", + "眼形", + "做活" + ] + }, + { + "id": "life_death_golden_chicken_corner_vital", + "title": "金鸡独立:角部急所", + "difficulty": "advanced", + "region": "corner", + "toPlay": "W", + "objective": "用角部一点破坏对方眼形和气数", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R2" + }, + { + "color": "W", + "point": "Q2" + }, + { + "color": "B", + "point": "R3" + }, + { + "color": "W", + "point": "Q3" + }, + { + "color": "B", + "point": "S4" + }, + { + "color": "W", + "point": "R4" + } + ], + "correctMoves": [ + { + "move": "S2", + "explanation": "站住角上一点,让对方眼位和气数不能兼顾。" + } + ], + "failureMoves": [ + { + "move": "Q1", + "why": "只破眼不紧气,对方可能借角部反先做活。" + } + ], + "teaching": { + "recognition": "金鸡独立常出现在角部一二线交界,关键是同一手兼顾破眼和紧气。", + "firstFeeling": "第一感找能站住、又让对方不能连回的角上一点。", + "explanation": "站住角上一点,让对方眼位和气数不能兼顾。", + "memoryCue": "金鸡独立不是形状好看,是一手同时破眼又紧气。", + "failureExplanation": "只破眼不紧气,对方可能借角部反先做活。" + }, + "patternCardIds": [ + "life_death_corner_throw_in_1_2_point", + "life_death_true_eye_false_eye", + "semeai_one_eye_beats_no_eye" + ], + "tags": [ + "金鸡独立", + "角部", + "急所", + "紧气" + ] + } + ], + "tesujiProblems": [ + { + "id": "tesuji_snapback_basic", + "title": "倒扑:基础型", + "difficulty": "basic", + "region": "corner", + "toPlay": "B", + "objective": "送一子后吃回,破眼或吃棋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + } + ], + "correctMoves": [ + { + "move": "C16", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "K11", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现倒扑时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "倒扑的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "倒扑", + "吃回", + "破眼" + ] + }, + { + "id": "tesuji_snapback_liberty", + "title": "倒扑:紧气型", + "difficulty": "standard", + "region": "corner", + "toPlay": "B", + "objective": "送一子后吃回,破眼或吃棋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + } + ], + "correctMoves": [ + { + "move": "D17", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "J10", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现倒扑时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "倒扑的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "倒扑", + "吃回", + "破眼" + ] + }, + { + "id": "tesuji_snapback_shape", + "title": "倒扑:坏形型", + "difficulty": "advanced", + "region": "corner", + "toPlay": "B", + "objective": "送一子后吃回,破眼或吃棋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + } + ], + "correctMoves": [ + { + "move": "E17", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "J11", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现倒扑时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "倒扑的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "倒扑", + "吃回", + "破眼" + ] + }, + { + "id": "tesuji_snapback_ko", + "title": "倒扑:造劫型", + "difficulty": "advanced", + "region": "corner", + "toPlay": "B", + "objective": "送一子后吃回,破眼或吃棋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + } + ], + "correctMoves": [ + { + "move": "C15", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "L10", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现倒扑时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "倒扑的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "倒扑", + "吃回", + "破眼" + ] + }, + { + "id": "tesuji_throw_in_basic", + "title": "扑:基础型", + "difficulty": "basic", + "region": "side", + "toPlay": "W", + "objective": "用扑入制造断点或气紧", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + } + ], + "correctMoves": [ + { + "move": "E16", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "L11", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现扑时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扑的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扑", + "破眼", + "气紧" + ] + }, + { + "id": "tesuji_throw_in_liberty", + "title": "扑:紧气型", + "difficulty": "standard", + "region": "side", + "toPlay": "W", + "objective": "用扑入制造断点或气紧", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + } + ], + "correctMoves": [ + { + "move": "K10", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "D4", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现扑时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扑的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扑", + "破眼", + "气紧" + ] + }, + { + "id": "tesuji_throw_in_shape", + "title": "扑:坏形型", + "difficulty": "advanced", + "region": "side", + "toPlay": "W", + "objective": "用扑入制造断点或气紧", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + } + ], + "correctMoves": [ + { + "move": "K11", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "C3", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现扑时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扑的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扑", + "破眼", + "气紧" + ] + }, + { + "id": "tesuji_throw_in_ko", + "title": "扑:造劫型", + "difficulty": "advanced", + "region": "side", + "toPlay": "W", + "objective": "用扑入制造断点或气紧", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + } + ], + "correctMoves": [ + { + "move": "J10", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "C4", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现扑时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扑的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扑", + "破眼", + "气紧" + ] + }, + { + "id": "tesuji_ladder_basic", + "title": "征子:基础型", + "difficulty": "basic", + "region": "center", + "toPlay": "B", + "objective": "判断征子方向和引征", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + } + ], + "correctMoves": [ + { + "move": "J11", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "D3", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现征子时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "征子的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "征子", + "引征", + "方向" + ] + }, + { + "id": "tesuji_ladder_liberty", + "title": "征子:紧气型", + "difficulty": "standard", + "region": "center", + "toPlay": "B", + "objective": "判断征子方向和引征", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + } + ], + "correctMoves": [ + { + "move": "L10", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "E3", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现征子时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "征子的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "征子", + "引征", + "方向" + ] + }, + { + "id": "tesuji_ladder_shape", + "title": "征子:坏形型", + "difficulty": "advanced", + "region": "center", + "toPlay": "B", + "objective": "判断征子方向和引征", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + } + ], + "correctMoves": [ + { + "move": "L11", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "C5", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现征子时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "征子的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "征子", + "引征", + "方向" + ] + }, + { + "id": "tesuji_ladder_ko", + "title": "征子:造劫型", + "difficulty": "advanced", + "region": "center", + "toPlay": "B", + "objective": "判断征子方向和引征", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + } + ], + "correctMoves": [ + { + "move": "D4", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "F4", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现征子时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "征子的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "征子", + "引征", + "方向" + ] + }, + { + "id": "tesuji_net_basic", + "title": "枷:基础型", + "difficulty": "basic", + "region": "center", + "toPlay": "W", + "objective": "不追着打吃,用网收住", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + } + ], + "correctMoves": [ + { + "move": "C3", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "E5", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现枷时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "枷的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "枷", + "封锁", + "轻重" + ] + }, + { + "id": "tesuji_net_liberty", + "title": "枷:紧气型", + "difficulty": "standard", + "region": "center", + "toPlay": "W", + "objective": "不追着打吃,用网收住", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + } + ], + "correctMoves": [ + { + "move": "C4", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "B3", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现枷时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "枷的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "枷", + "封锁", + "轻重" + ] + }, + { + "id": "tesuji_net_shape", + "title": "枷:坏形型", + "difficulty": "advanced", + "region": "center", + "toPlay": "W", + "objective": "不追着打吃,用网收住", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + } + ], + "correctMoves": [ + { + "move": "D3", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "B4", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现枷时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "枷的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "枷", + "封锁", + "轻重" + ] + }, + { + "id": "tesuji_net_ko", + "title": "枷:造劫型", + "difficulty": "advanced", + "region": "center", + "toPlay": "W", + "objective": "不追着打吃,用网收住", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + } + ], + "correctMoves": [ + { + "move": "E3", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "D2", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现枷时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "枷的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "枷", + "封锁", + "轻重" + ] + }, + { + "id": "tesuji_squeeze_basic", + "title": "滚打包收:基础型", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "弃子换取厚势和先手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + } + ], + "correctMoves": [ + { + "move": "C5", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "E2", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现滚打包收时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "滚打包收的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "滚打包收", + "弃子", + "厚势" + ] + }, + { + "id": "tesuji_squeeze_liberty", + "title": "滚打包收:紧气型", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "弃子换取厚势和先手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + } + ], + "correctMoves": [ + { + "move": "F4", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "Q16", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现滚打包收时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "滚打包收的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "滚打包收", + "弃子", + "厚势" + ] + }, + { + "id": "tesuji_squeeze_shape", + "title": "滚打包收:坏形型", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "弃子换取厚势和先手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + } + ], + "correctMoves": [ + { + "move": "E5", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "R17", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现滚打包收时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "滚打包收的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "滚打包收", + "弃子", + "厚势" + ] + }, + { + "id": "tesuji_squeeze_ko", + "title": "滚打包收:造劫型", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "弃子换取厚势和先手", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + } + ], + "correctMoves": [ + { + "move": "B3", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "Q17", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现滚打包收时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "滚打包收的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "滚打包收", + "弃子", + "厚势" + ] + }, + { + "id": "tesuji_wedge_basic", + "title": "挖:基础型", + "difficulty": "basic", + "region": "side", + "toPlay": "W", + "objective": "挖断连接,制造两边无法兼顾", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + } + ], + "correctMoves": [ + { + "move": "B4", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "R16", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现挖时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "挖的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "挖", + "断点", + "连接" + ] + }, + { + "id": "tesuji_wedge_liberty", + "title": "挖:紧气型", + "difficulty": "standard", + "region": "side", + "toPlay": "W", + "objective": "挖断连接,制造两边无法兼顾", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + } + ], + "correctMoves": [ + { + "move": "D2", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "P17", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现挖时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "挖的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "挖", + "断点", + "连接" + ] + }, + { + "id": "tesuji_wedge_shape", + "title": "挖:坏形型", + "difficulty": "advanced", + "region": "side", + "toPlay": "W", + "objective": "挖断连接,制造两边无法兼顾", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + } + ], + "correctMoves": [ + { + "move": "E2", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "Q15", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现挖时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "挖的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "挖", + "断点", + "连接" + ] + }, + { + "id": "tesuji_wedge_ko", + "title": "挖:造劫型", + "difficulty": "advanced", + "region": "side", + "toPlay": "W", + "objective": "挖断连接,制造两边无法兼顾", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + } + ], + "correctMoves": [ + { + "move": "Q16", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "R15", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现挖时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "挖的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "挖", + "断点", + "连接" + ] + }, + { + "id": "tesuji_crosscut_basic", + "title": "扭十字:基础型", + "difficulty": "basic", + "region": "center", + "toPlay": "B", + "objective": "扭十字后先找气紧的一边", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + } + ], + "correctMoves": [ + { + "move": "R17", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "P16", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现扭十字时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扭十字的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扭十字", + "气", + "次序" + ] + }, + { + "id": "tesuji_crosscut_liberty", + "title": "扭十字:紧气型", + "difficulty": "standard", + "region": "center", + "toPlay": "B", + "objective": "扭十字后先找气紧的一边", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + } + ], + "correctMoves": [ + { + "move": "Q17", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "D16", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现扭十字时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扭十字的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扭十字", + "气", + "次序" + ] + }, + { + "id": "tesuji_crosscut_shape", + "title": "扭十字:坏形型", + "difficulty": "advanced", + "region": "center", + "toPlay": "B", + "objective": "扭十字后先找气紧的一边", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + } + ], + "correctMoves": [ + { + "move": "R16", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "C17", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现扭十字时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扭十字的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扭十字", + "气", + "次序" + ] + }, + { + "id": "tesuji_crosscut_ko", + "title": "扭十字:造劫型", + "difficulty": "advanced", + "region": "center", + "toPlay": "B", + "objective": "扭十字后先找气紧的一边", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + } + ], + "correctMoves": [ + { + "move": "P17", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "C16", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现扭十字时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扭十字的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扭十字", + "气", + "次序" + ] + }, + { + "id": "tesuji_cut_basic", + "title": "断:基础型", + "difficulty": "basic", + "region": "center", + "toPlay": "W", + "objective": "断不是目的,断后要有攻击目标", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + } + ], + "correctMoves": [ + { + "move": "Q15", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "D17", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现断时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "断的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "断", + "攻击", + "薄棋" + ] + }, + { + "id": "tesuji_cut_liberty", + "title": "断:紧气型", + "difficulty": "standard", + "region": "center", + "toPlay": "W", + "objective": "断不是目的,断后要有攻击目标", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + } + ], + "correctMoves": [ + { + "move": "R15", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "E17", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现断时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "断的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "断", + "攻击", + "薄棋" + ] + }, + { + "id": "tesuji_cut_shape", + "title": "断:坏形型", + "difficulty": "advanced", + "region": "center", + "toPlay": "W", + "objective": "断不是目的,断后要有攻击目标", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + } + ], + "correctMoves": [ + { + "move": "P16", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "C15", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现断时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "断的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "断", + "攻击", + "薄棋" + ] + }, + { + "id": "tesuji_cut_ko", + "title": "断:造劫型", + "difficulty": "advanced", + "region": "center", + "toPlay": "W", + "objective": "断不是目的,断后要有攻击目标", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + } + ], + "correctMoves": [ + { + "move": "D16", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "E16", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现断时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "断的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "断", + "攻击", + "薄棋" + ] + }, + { + "id": "tesuji_clamp_basic", + "title": "夹:基础型", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "用夹逼迫对方形状变坏", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + } + ], + "correctMoves": [ + { + "move": "C17", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "K10", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现夹时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "夹的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "夹", + "形", + "边上" + ] + }, + { + "id": "tesuji_clamp_liberty", + "title": "夹:紧气型", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "用夹逼迫对方形状变坏", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + } + ], + "correctMoves": [ + { + "move": "C16", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "K11", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现夹时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "夹的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "夹", + "形", + "边上" + ] + }, + { + "id": "tesuji_clamp_shape", + "title": "夹:坏形型", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "用夹逼迫对方形状变坏", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + } + ], + "correctMoves": [ + { + "move": "D17", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "J10", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现夹时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "夹的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "夹", + "形", + "边上" + ] + }, + { + "id": "tesuji_clamp_ko", + "title": "夹:造劫型", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "用夹逼迫对方形状变坏", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + } + ], + "correctMoves": [ + { + "move": "E17", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "J11", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现夹时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "夹的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "夹", + "形", + "边上" + ] + }, + { + "id": "tesuji_peep_basic", + "title": "窥:基础型", + "difficulty": "basic", + "region": "center", + "toPlay": "W", + "objective": "用窥交换制造方向", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + } + ], + "correctMoves": [ + { + "move": "C15", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "L10", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现窥时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "窥的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "窥", + "交换", + "方向" + ] + }, + { + "id": "tesuji_peep_liberty", + "title": "窥:紧气型", + "difficulty": "standard", + "region": "center", + "toPlay": "W", + "objective": "用窥交换制造方向", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + } + ], + "correctMoves": [ + { + "move": "E16", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "L11", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现窥时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "窥的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "窥", + "交换", + "方向" + ] + }, + { + "id": "tesuji_peep_shape", + "title": "窥:坏形型", + "difficulty": "advanced", + "region": "center", + "toPlay": "W", + "objective": "用窥交换制造方向", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + } + ], + "correctMoves": [ + { + "move": "K10", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "D4", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现窥时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "窥的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "窥", + "交换", + "方向" + ] + }, + { + "id": "tesuji_peep_ko", + "title": "窥:造劫型", + "difficulty": "advanced", + "region": "center", + "toPlay": "W", + "objective": "用窥交换制造方向", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + }, + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + } + ], + "correctMoves": [ + { + "move": "K11", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "C3", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现窥时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "窥的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "窥", + "交换", + "方向" + ] + }, + { + "id": "tesuji_monkey_jump_basic", + "title": "猴子翻跟斗:基础型", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "边上官子或侵入的最大手筋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + }, + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + } + ], + "correctMoves": [ + { + "move": "J10", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "C4", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现猴子翻跟斗时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "猴子翻跟斗的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "猴子", + "官子", + "边上" + ] + }, + { + "id": "tesuji_monkey_jump_liberty", + "title": "猴子翻跟斗:紧气型", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "边上官子或侵入的最大手筋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + }, + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + } + ], + "correctMoves": [ + { + "move": "J11", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "D3", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现猴子翻跟斗时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "猴子翻跟斗的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "猴子", + "官子", + "边上" + ] + }, + { + "id": "tesuji_monkey_jump_shape", + "title": "猴子翻跟斗:坏形型", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "边上官子或侵入的最大手筋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + }, + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + } + ], + "correctMoves": [ + { + "move": "L10", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "E3", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现猴子翻跟斗时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "猴子翻跟斗的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "猴子", + "官子", + "边上" + ] + }, + { + "id": "tesuji_monkey_jump_ko", + "title": "猴子翻跟斗:造劫型", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "边上官子或侵入的最大手筋", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + }, + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + } + ], + "correctMoves": [ + { + "move": "L11", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "C5", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现猴子翻跟斗时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "猴子翻跟斗的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "猴子", + "官子", + "边上" + ] + }, + { + "id": "tesuji_hane_head_basic", + "title": "扳头:基础型", + "difficulty": "basic", + "region": "center", + "toPlay": "W", + "objective": "扳在两子头上压缩对方形状", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + }, + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + } + ], + "correctMoves": [ + { + "move": "D4", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "F4", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现扳头时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扳头的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扳头", + "形", + "压迫" + ] + }, + { + "id": "tesuji_hane_head_liberty", + "title": "扳头:紧气型", + "difficulty": "standard", + "region": "center", + "toPlay": "W", + "objective": "扳在两子头上压缩对方形状", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C17" + }, + { + "color": "W", + "point": "E17" + }, + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + } + ], + "correctMoves": [ + { + "move": "C3", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "E5", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现扳头时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扳头的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扳头", + "形", + "压迫" + ] + }, + { + "id": "tesuji_hane_head_shape", + "title": "扳头:坏形型", + "difficulty": "advanced", + "region": "center", + "toPlay": "W", + "objective": "扳在两子头上压缩对方形状", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C16" + }, + { + "color": "W", + "point": "C15" + }, + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + } + ], + "correctMoves": [ + { + "move": "C4", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "B3", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现扳头时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扳头的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扳头", + "形", + "压迫" + ] + }, + { + "id": "tesuji_hane_head_ko", + "title": "扳头:造劫型", + "difficulty": "advanced", + "region": "center", + "toPlay": "W", + "objective": "扳在两子头上压缩对方形状", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D17" + }, + { + "color": "W", + "point": "E16" + }, + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + } + ], + "correctMoves": [ + { + "move": "D3", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "B4", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现扳头时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "扳头的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "扳头", + "形", + "压迫" + ] + }, + { + "id": "tesuji_double_atari_basic", + "title": "双打:基础型", + "difficulty": "basic", + "region": "center", + "toPlay": "B", + "objective": "用双打让对方无法两边兼顾", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E17" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + } + ], + "correctMoves": [ + { + "move": "E3", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "D2", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现双打时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "双打的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "双打", + "吃子", + "断点" + ] + }, + { + "id": "tesuji_double_atari_liberty", + "title": "双打:紧气型", + "difficulty": "standard", + "region": "center", + "toPlay": "B", + "objective": "用双打让对方无法两边兼顾", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C15" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + } + ], + "correctMoves": [ + { + "move": "C5", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "E2", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现双打时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "双打的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "双打", + "吃子", + "断点" + ] + }, + { + "id": "tesuji_double_atari_shape", + "title": "双打:坏形型", + "difficulty": "advanced", + "region": "center", + "toPlay": "B", + "objective": "用双打让对方无法两边兼顾", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "E16" + }, + { + "color": "W", + "point": "J10" + }, + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + } + ], + "correctMoves": [ + { + "move": "F4", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "Q16", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现双打时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "双打的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "双打", + "吃子", + "断点" + ] + }, + { + "id": "tesuji_double_atari_ko", + "title": "双打:造劫型", + "difficulty": "advanced", + "region": "center", + "toPlay": "B", + "objective": "用双打让对方无法两边兼顾", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "K10" + }, + { + "color": "W", + "point": "J11" + }, + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + } + ], + "correctMoves": [ + { + "move": "E5", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "R17", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现双打时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "双打的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "双打", + "吃子", + "断点" + ] + }, + { + "id": "tesuji_geta_basic", + "title": "枷吃:基础型", + "difficulty": "basic", + "region": "corner", + "toPlay": "W", + "objective": "不用追杀,直接封住逃路", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "K11" + }, + { + "color": "W", + "point": "L10" + }, + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + } + ], + "correctMoves": [ + { + "move": "B3", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "Q17", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现枷吃时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "枷吃的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "枷吃", + "封锁", + "角部" + ] + }, + { + "id": "tesuji_geta_liberty", + "title": "枷吃:紧气型", + "difficulty": "standard", + "region": "corner", + "toPlay": "W", + "objective": "不用追杀,直接封住逃路", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "L11" + }, + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + } + ], + "correctMoves": [ + { + "move": "B4", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "R16", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现枷吃时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "枷吃的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "枷吃", + "封锁", + "角部" + ] + }, + { + "id": "tesuji_geta_shape", + "title": "枷吃:坏形型", + "difficulty": "advanced", + "region": "corner", + "toPlay": "W", + "objective": "不用追杀,直接封住逃路", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "F4" + }, + { + "color": "B", + "point": "B4" + }, + { + "color": "W", + "point": "Q16" + }, + { + "color": "B", + "point": "R16" + }, + { + "color": "W", + "point": "R15" + } + ], + "correctMoves": [ + { + "move": "D2", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "P17", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现枷吃时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "枷吃的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "枷吃", + "封锁", + "角部" + ] + }, + { + "id": "tesuji_geta_ko", + "title": "枷吃:造劫型", + "difficulty": "advanced", + "region": "corner", + "toPlay": "W", + "objective": "不用追杀,直接封住逃路", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "C3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "E5" + }, + { + "color": "B", + "point": "D2" + }, + { + "color": "W", + "point": "R17" + }, + { + "color": "B", + "point": "P17" + }, + { + "color": "W", + "point": "P16" + } + ], + "correctMoves": [ + { + "move": "E2", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "Q15", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现枷吃时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "枷吃的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "枷吃", + "封锁", + "角部" + ] + }, + { + "id": "tesuji_sacrifice_cut_basic", + "title": "弃子断:基础型", + "difficulty": "basic", + "region": "side", + "toPlay": "B", + "objective": "舍小子换大形,逼对方气紧", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "L11" + }, + { + "color": "W", + "point": "C4" + }, + { + "color": "B", + "point": "C5" + }, + { + "color": "W", + "point": "B3" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "Q17" + }, + { + "color": "B", + "point": "Q15" + }, + { + "color": "W", + "point": "D16" + } + ], + "correctMoves": [ + { + "move": "Q16", + "explanation": "先看到对方气紧,再找最短的手筋。" + } + ], + "failureMoves": [ + { + "move": "R15", + "why": "直接打吃通常让对方逃出。" + } + ], + "teaching": { + "recognition": "出现弃子断时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先看到对方气紧,再找最短的手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "弃子断的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "直接打吃通常让对方逃出。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "弃子", + "断", + "转换" + ] + }, + { + "id": "tesuji_sacrifice_cut_liberty", + "title": "弃子断:紧气型", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "舍小子换大形,逼对方气紧", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "D4" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "F4" + }, + { + "color": "W", + "point": "B4" + }, + { + "color": "B", + "point": "Q16" + }, + { + "color": "W", + "point": "R16" + }, + { + "color": "B", + "point": "R15" + }, + { + "color": "W", + "point": "C17" + } + ], + "correctMoves": [ + { + "move": "R17", + "explanation": "先紧关键气,再出手筋。" + } + ], + "failureMoves": [ + { + "move": "P16", + "why": "先吃小子会送给对方外气。" + } + ], + "teaching": { + "recognition": "出现弃子断时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "先紧关键气,再出手筋。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "弃子断的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "先吃小子会送给对方外气。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "弃子", + "断", + "转换" + ] + }, + { + "id": "tesuji_sacrifice_cut_shape", + "title": "弃子断:坏形型", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "舍小子换大形,逼对方气紧", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "E3" + }, + { + "color": "B", + "point": "E5" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "R17" + }, + { + "color": "W", + "point": "P17" + }, + { + "color": "B", + "point": "P16" + }, + { + "color": "W", + "point": "C16" + } + ], + "correctMoves": [ + { + "move": "Q17", + "explanation": "逼对方走成愚形,再获得先手。" + } + ], + "failureMoves": [ + { + "move": "D16", + "why": "只顾吃子会失去压迫方向。" + } + ], + "teaching": { + "recognition": "出现弃子断时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "逼对方走成愚形,再获得先手。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "弃子断的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "只顾吃子会失去压迫方向。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "弃子", + "断", + "转换" + ] + }, + { + "id": "tesuji_sacrifice_cut_ko", + "title": "弃子断:造劫型", + "difficulty": "advanced", + "region": "side", + "toPlay": "B", + "objective": "舍小子换大形,逼对方气紧", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C4" + }, + { + "color": "W", + "point": "C5" + }, + { + "color": "B", + "point": "B3" + }, + { + "color": "W", + "point": "E2" + }, + { + "color": "B", + "point": "Q17" + }, + { + "color": "W", + "point": "Q15" + }, + { + "color": "B", + "point": "D16" + }, + { + "color": "W", + "point": "D17" + } + ], + "correctMoves": [ + { + "move": "R16", + "explanation": "手筋不一定杀净,有时是制造劫争。" + } + ], + "failureMoves": [ + { + "move": "C17", + "why": "不看劫材就开劫,收益可能变负。" + } + ], + "teaching": { + "recognition": "出现弃子断时,先看断点、气紧和对方不能兼顾的地方。", + "tesujiIdea": "手筋不一定杀净,有时是制造劫争。", + "firstHint": "先找最急的气和断点,不急着吃最显眼的子。", + "memoryCue": "弃子断的价值在次序,不在第一眼吃多少子。", + "failureExplanation": "不看劫材就开劫,收益可能变负。" + }, + "patternCardIds": [ + "tesuji_snapback_throw_in", + "tesuji_ladder_net_choice", + "tesuji_wedge_split_connection" + ], + "tags": [ + "弃子", + "断", + "转换" + ] + }, + { + "id": "tesuji_connect_and_die_basic", + "title": "接不归:基础型", + "difficulty": "standard", + "region": "side", + "toPlay": "B", + "objective": "利用断点和气紧,让对方看似能接却接不回", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C3" + }, + { + "color": "W", + "point": "D3" + }, + { + "color": "B", + "point": "E3" + }, + { + "color": "W", + "point": "D4" + }, + { + "color": "B", + "point": "E4" + }, + { + "color": "W", + "point": "C4" + } + ], + "correctMoves": [ + { + "move": "D2", + "explanation": "先压住连接点,让对方接上也会被气紧吃掉。" + } + ], + "failureMoves": [ + { + "move": "F3", + "why": "直接追吃给了对方连接和长气的机会。" + } + ], + "teaching": { + "recognition": "接不归要找对方最想连接的一点,再数接上后的气数。", + "tesujiIdea": "先压住连接点,让对方接上也会被气紧吃掉。", + "firstHint": "别急着打吃,先问对方接上以后有没有气。", + "memoryCue": "接不归的重点是接上也死,不是表面断开。", + "failureExplanation": "直接追吃给了对方连接和长气的机会。" + }, + "patternCardIds": [ + "tesuji_shortage_of_liberties", + "tesuji_wedge_split_connection", + "tesuji_crosscut_reading_order" + ], + "tags": [ + "接不归", + "断点", + "气紧", + "手筋" + ] + }, + { + "id": "tesuji_probe_before_sacrifice_basic", + "title": "先试应手再弃子:探路型", + "difficulty": "standard", + "region": "center", + "toPlay": "B", + "objective": "先利用试应手确认对方应法,再决定弃子转换", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "J10" + }, + { + "color": "W", + "point": "K10" + }, + { + "color": "B", + "point": "L10" + }, + { + "color": "W", + "point": "K11" + }, + { + "color": "B", + "point": "J11" + }, + { + "color": "W", + "point": "L11" + } + ], + "correctMoves": [ + { + "move": "K9", + "explanation": "先试对方应法,确认断点和气数后再弃子转换。" + } + ], + "failureMoves": [ + { + "move": "M10", + "why": "直接弃子没有先手交换,容易被对方简明吃住。" + } + ], + "teaching": { + "recognition": "有些弃子不是第一手就扔,而是先用试应手确认对方形状。", + "tesujiIdea": "先试对方应法,确认断点和气数后再弃子转换。", + "firstHint": "先找对方必须回应的一手,再看弃子是否成立。", + "memoryCue": "先问一声,再决定弃不弃。", + "failureExplanation": "直接弃子没有先手交换,容易被对方简明吃住。" + }, + "patternCardIds": [ + "shape_probe_then_tenuki_aji", + "tesuji_squeeze_shape_profit", + "tesuji_wedge_split_connection" + ], + "tags": [ + "试应手", + "弃子", + "转换", + "手筋" + ] + }, + { + "id": "tesuji_oil_rat_endgame", + "title": "老鼠偷油:边线官子", + "difficulty": "advanced", + "region": "side", + "toPlay": "W", + "objective": "在边线上利用连续先手侵入偷取实地", + "sourceKind": "common-pattern", + "initialStones": [ + { + "color": "B", + "point": "C2" + }, + { + "color": "W", + "point": "D2" + }, + { + "color": "B", + "point": "E2" + }, + { + "color": "W", + "point": "F2" + }, + { + "color": "B", + "point": "D3" + }, + { + "color": "W", + "point": "E3" + } + ], + "correctMoves": [ + { + "move": "B2", + "explanation": "从边线最深处试探,利用连续先手把对方实地掏薄。" + } + ], + "failureMoves": [ + { + "move": "G2", + "why": "从外面收官太慢,失去边线上连续先手的价值。" + } + ], + "teaching": { + "recognition": "老鼠偷油常在边线二路出现,重点是连续先手和对方眼位薄味。", + "tesujiIdea": "从边线最深处试探,利用连续先手把对方实地掏薄。", + "firstHint": "先找最里面的先手点,再判断对方能不能强硬反击。", + "memoryCue": "边上官子先看里面有没有连续先手。", + "failureExplanation": "从外面收官太慢,失去边线上连续先手的价值。" + }, + "patternCardIds": [ + "tesuji_monkey_jump_endgame", + "life_death_side_second_line_hane", + "tesuji_clamp_under_connection" + ], + "tags": [ + "老鼠偷油", + "官子", + "边上", + "手筋" + ] + } + ] +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 689987a..31a7eab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -KataSensei is a local-first desktop workbench for Go education. It combines a professional board/review UI, KataGo analysis, a local knowledge base, and an agentic teacher runtime. +GoMentor is a local-first desktop workbench for Go education. It combines a professional board/review UI, KataGo analysis, a local knowledge base, and an agentic teacher runtime. ## Layers @@ -88,7 +88,7 @@ See [TEACHER_AGENT.md](./TEACHER_AGENT.md). Default local home: ```text -~/.katasensei/ +~/.gomentor/ library/ Imported and synced SGFs reports/ Scripted review reports teacher-reports/ Agent-generated reports @@ -103,7 +103,7 @@ LLM API keys are stored through Electron `safeStorage` when available. The rende `src/main/services/katagoRuntime.ts` resolves runtime assets in this order: 1. Bundled `data/katago` runtime inside the app/resources. -2. User-managed `~/.katasensei/katago`. +2. User-managed `~/.gomentor/katago`. 3. System-installed `katago`. 4. Development fallback models such as `~/.katago/models/latest-kata1.bin.gz`. @@ -115,7 +115,7 @@ The analysis config is generated in the user data directory so the external Kata - Current-move multimodal analysis sends only board PNG, structured KataGo facts, selected knowledge cards, and the user prompt to the configured LLM endpoint. - Batch review disables per-game LLM calls and summarizes the aggregate. - Web search receives generic Go topic queries only. -- File-opening IPC is constrained to KataSensei-managed directories. +- File-opening IPC is constrained to GoMentor-managed directories. ## Verification Baseline diff --git a/docs/DESKTOP_APP_UX_REFERENCES.md b/docs/DESKTOP_APP_UX_REFERENCES.md new file mode 100644 index 0000000..b8a2de3 --- /dev/null +++ b/docs/DESKTOP_APP_UX_REFERENCES.md @@ -0,0 +1,27 @@ +# Desktop App UX References + +GoMentor is a desktop workbench, not a browser-first web app. Sprint 7 desktop polish follows these product references without copying their UI. + +## Reference Directions + +- Cherry Studio: desktop-first model settings, provider management, cross-platform release discipline. +- OpenCode / OpenChamber / Palot: agent sessions, tool output streams, multi-project desktop workflows. +- Cursor / Windsurf / Kiro: AI editor layout, command palette, agent panel hierarchy, compact status surfaces. +- VS Code: native menu, command palette, status bar, file/workspace mental model. +- Claude Desktop: quiet MCP/tool setup and minimal preferences surface. + +## GoMentor Decisions + +- Native Electron menu owns global commands such as import, settings, command palette, and analysis actions. +- Renderer uses a desktop shell with titlebar, workbench, and statusbar instead of a page-only layout. +- Preferences are a desktop modal window, not a chat-panel drawer. +- Teacher UI is an agent editor with Thread / Turn / Item stream language. +- UI Gallery remains internal visual QA and must not become a user-facing route. + +## Acceptance + +- Main workflow should feel like an installed macOS/Windows/Linux app. +- `Command/Ctrl+K` opens a command palette. +- `Command/Ctrl+,` opens Preferences. +- Status bar always shows selected game, move, winrate state, KataGo state, LLM state, and current task. +- No release should be tagged until visual QA evidence confirms the desktop shell on macOS and Windows. diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md new file mode 100644 index 0000000..9146777 --- /dev/null +++ b/docs/DIAGNOSTICS.md @@ -0,0 +1,35 @@ +# Diagnostics UX + +Diagnostics should be the first user-facing reliability layer. + +## Gate states + +- `ready`: Enter workbench automatically. +- `fixable`: Show warnings, allow continue. +- `blocked`: Do not enter workbench until fixed. + +## Required checks + +- App user directory writable. +- Bundled KataGo binary available and executable. +- Default KataGo model available. +- KataGo runtime config can be generated. + +## Optional checks + +- Claude-compatible proxy configured. +- Proxy API key valid. +- Model supports image input. +- Fox sync endpoint usable. + +## UX standard + +Every check should show: + +- status +- title +- detail +- action +- optional technical detail + +The user should not need terminal logs to understand common failures. diff --git a/docs/GITHUB_ISSUES_P0.md b/docs/GITHUB_ISSUES_P0.md new file mode 100644 index 0000000..085b3b3 --- /dev/null +++ b/docs/GITHUB_ISSUES_P0.md @@ -0,0 +1,54 @@ +# P0 GitHub Issues + +## P0-001 Security and repo governance +- Revoke exposed token +- Add `.env.example` +- Verify `.gitignore` protects local secrets and KataGo binaries + +## P0-002 Startup diagnostics +- Add diagnostics service +- Add renderer diagnostics gate +- Add error code system + +## P0-003 Bundled KataGo runtime +- Add runtime manifest +- Package platform-specific binary +- Package b18 default model +- Add smoke check script + +## P0-004 Claude compatible provider +- Add provider interface +- Add OpenAI-compatible adapter +- Add image probe +- Add timeout/retry handling + +## P0-005 Student profile by Fox nickname +- Add StudentProfile store +- Add alias handling +- Attach SGF and Fox games to student + +## P0-006 Knowledge cards +- Add schema +- Add 48 P0 cards +- Add local search +- Wire into teacher prompt + +## P0-007 Teacher structured output +- Add result schema +- Add card renderer +- Update teacher runtime prompts + +## P0-008 UI upgrade phase 1 +- Diagnostics page +- Runtime settings panel +- Teacher result cards + +## P0-009 Library data integration +- SGF hash dedupe +- Fox dedupe +- Recent N games by student + +## P0-010 Release smoke test +- macOS smoke test +- Windows smoke test +- Chinese path smoke test diff --git a/docs/KATAGO_ASSETS.md b/docs/KATAGO_ASSETS.md new file mode 100644 index 0000000..3280c67 --- /dev/null +++ b/docs/KATAGO_ASSETS.md @@ -0,0 +1,64 @@ +# KataGo Assets Strategy + +## P0 decision + +GoMentor P0 beta should ship with a bundled KataGo binary and one default model. The default model should be the b18 recommended model already referenced by the project. + +Supported P0 beta platforms: + +- macOS arm64 +- macOS x64 +- Windows x64 + +Windows ARM64 is not supported for `v0.2.0-beta.1`. + +## Repository policy + +Large binaries and models should not be committed as normal Git blobs. Use one of these options: + +1. Git LFS for binaries/models. +2. CI artifact download before release packaging. +3. Local release build preparation via `scripts/prepare_katago_assets.mjs`. + +## Expected runtime layout + +```text +data/katago/ + manifest.json + bin/darwin-arm64/katago + bin/darwin-x64/katago + bin/win32-x64/katago.exe + models/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz +``` + +Do not add or publish `bin/win32-arm64/katago.exe` until a tested official or trusted Windows ARM64 KataGo runtime is available and the manifest/check scripts are updated. + +## Scripts + +Prepare assets: + +```bash +GOMENTOR_KATAGO_BINARY=/path/to/katago \ +GOMENTOR_KATAGO_MODEL=/path/to/model.bin.gz \ +node scripts/prepare_katago_assets.mjs +``` + +Check for dev: + +```bash +node scripts/check_katago_assets.mjs --mode=dev +``` + +Check for release: + +```bash +node scripts/check_katago_assets.mjs --mode=release +``` + +Release mode must fail if assets are missing. + +## Diagnostics behavior + +- Dev build: missing assets can be warning. +- Release build: missing assets should be blocked. +- LLM missing should be warning, not blocked. diff --git a/docs/KATAGO_RELEASE_ASSETS_CHECKLIST.md b/docs/KATAGO_RELEASE_ASSETS_CHECKLIST.md new file mode 100644 index 0000000..4f6da92 --- /dev/null +++ b/docs/KATAGO_RELEASE_ASSETS_CHECKLIST.md @@ -0,0 +1,55 @@ +# KataGo Release Assets Checklist + +## P0 策略 + +- 不把大 binary/model 普通 Git 提交。 +- Release 时准备平台对应 binary 和默认 b18 model。 +- 安装包必须包含运行所需资源。 +- P0 beta 支持 macOS arm64/x64 和 Windows x64。Windows ARM64 暂不支持。 + +## 资源布局 + +```text +data/katago/ + manifest.json + bin/ + darwin-arm64/katago + darwin-x64/katago + win32-x64/katago.exe + models/ + .bin.gz +``` + +## 检查命令 + +开发模式: + +```bash +node scripts/check_katago_assets.mjs --mode=dev +``` + +发布模式: + +```bash +node scripts/check_katago_assets.mjs --mode=release +node scripts/p0_release_candidate_check.mjs --mode=release +``` + +## checksum + +Release 前应记录: + +- binary SHA256 +- model SHA256 +- 下载来源 +- KataGo 版本 +- 模型名称 + +## 不通过标准 + +- manifest 指向不存在的文件 +- Windows 打包没有 `katago.exe` +- 生成或上传了 `win-arm64` beta 产物 +- macOS 打包没有可执行权限 +- 默认模型缺失 +- release mode 下检查仍只有 warning 而不是 ready diff --git a/docs/KNOWLEDGE_MATCHING.md b/docs/KNOWLEDGE_MATCHING.md new file mode 100644 index 0000000..d3880aa --- /dev/null +++ b/docs/KNOWLEDGE_MATCHING.md @@ -0,0 +1,82 @@ +# GoMentor Knowledge Matching + +GoMentor uses KataGo as the factual judge and the local knowledge base as the teaching layer. The knowledge system should not only answer keyword questions; it should recognize common Go patterns well enough for the AI teacher to explain joseki, life-and-death shapes, tesuji, and training memory cues. + +## Knowledge Layers + +1. **Concept cards**: broad teaching ideas in `data/knowledge/p0-cards.json`, such as direction, thickness, shape, endgame, and review method. +2. **Markdown essays**: longer YiGo-style teaching notes under `data/knowledge/**`. +3. **Pattern cards**: structured joseki, tsumego, tesuji, and shape cards in `data/knowledge/pattern-cards.json`. +4. **Source registry**: licensing and usage notes in `data/knowledge/source-registry.json`. + +Pattern cards are the most important layer for professional-looking teaching. Each card stores: + +- `title`, `category`, `patternType` +- `phase`, `regions`, `levels` +- tags and aliases, including Chinese and common English names +- board signals such as `3-3`, `4-4`, `second-line`, `eye-shape`, `capture` +- trigger rules for recent moves, KataGo candidate moves, PV moves, loss size, and judgement +- canonical shape notes and example coordinates +- variations, choosing conditions, warnings +- teacher-facing recognition, correct idea, memory cue, common mistake, and drill + +## Retrieval Rules + +`searchKnowledge()` scores pattern cards with several independent signals: + +- game phase: opening / middlegame / endgame +- board region: corner / side / center +- recent move geometry +- played move +- KataGo top candidate moves +- KataGo principal variations +- loss size and judgement +- user prompt and student profile tags + +This means the teacher can retrieve a joseki card even when the user does not type the joseki name. For example, if the current position is in the corner, early in the game, and KataGo candidates include a 3-3 point, the star-point 3-3 cards become strong matches. + +## Teaching Guardrails + +Pattern matching is helpful but not omniscient. The teacher prompt tells the model: + +- If the match is exact enough, say “这是某某定式/死活型”. +- If only the shape is similar, say “这像某某型”. +- Do not invent coordinates, winrate, score lead, joseki names, or variations. +- KataGo data decides whether the actual move was good or bad. +- Knowledge cards explain why the pattern is memorable and how the student should train it. + +## GitHub Source Review + +The knowledge base is expanded from public research, but GoMentor does not blindly import raw SGF/problem data. + +- `sanderland/tsumego`: useful as a tsumego product and category reference. The repository has an MIT-style license, but the problem folders reference classic book collections, so raw positions are not imported. +- `SzalonySamuraj/Joseki-Master`: MIT licensed. Its collection taxonomy is useful for star-point and komoku joseki coverage planning. +- `billyellow/Kogo-s-Joseki-Dictionary`: no explicit license found. Do not import SGF data. +- `online-go/godojo-server`: joseki-feature server, but no explicit license found during review. Do not import code/data. +- `kovarex/tsumego-hero`: useful product architecture reference, but no top-level license found during review. Do not import problem data. +- `Josekipedia game collection`: states that its game database is Creative Commons. Treat as a future import candidate only after exact license terms and attribution handling are confirmed. +- `Wikipedia: Life and death` and `Wikipedia: Go`: useful for broad terminology checks around eyes, life status, semeai, ko, seki, corners, sides, and joseki. GoMentor uses original teaching text rather than copying article prose. +- `goproblems.com best practices`: useful as problem taxonomy reference. Do not import problem text or diagrams without permission. +- OGS Creative Commons discussion: useful policy reminder that archived or publicly posted Go books may still be all-rights-reserved. + +When adding future material, keep the source registry updated with the review decision. A source marked `do-not-import` may still inspire original teaching categories, but it must not be copied into packaged data. + +## Expansion Plan + +Add future cards in small, verified batches: + +1. Common star-point joseki families. +2. Common 3-4 point joseki families. +3. Corner life-and-death shapes. +4. Side life-and-death shapes. +5. Tesuji patterns: snapback, ladder, net, throw-in, shortage of liberties. +6. Common bad-shape corrections. + +Avoid copying copyrighted problem collections. Store original teaching descriptions, compact pattern metadata, and links or citations only when the source license allows it. + +Current pattern-card coverage includes 44 original cards across joseki, life-and-death, tesuji, and shape. The target for the next milestone is not just more cards; it is better matching precision: + +- Add exact corner coordinate fingerprints for the most common star-point and komoku joseki. +- Add local-shape normalization so rotated and mirrored tsumego patterns match the same card. +- Add KataGo PV motif extraction, such as ko, throw-in, snapback, connection, cut, cap, and monkey jump. +- Add attribution fields if future cards are derived from explicitly licensed datasets. diff --git a/docs/KNOWLEDGE_MATCHING_QA.md b/docs/KNOWLEDGE_MATCHING_QA.md new file mode 100644 index 0000000..3c65fb2 --- /dev/null +++ b/docs/KNOWLEDGE_MATCHING_QA.md @@ -0,0 +1,79 @@ +# GoMentor Knowledge Matching QA + +This document records the current knowledge coverage and the matching quality rules used by the Go teacher agent. + +## Current Coverage + +- Joseki / fuseki lines: 64 +- Life-and-death problems: 124 +- Tesuji problems: 63 +- Pattern teaching cards: 44 + +The catalog already covers the P0 target breadth: + +- Star point, 3-3 invasion, star point approach, komoku approach, avalanche, taisha, double approach, magic sword, Chinese fuseki, Sanrensei, Kobayashi, Mini Chinese, Nirensei, enclosure reduction, pincer joseki. +- True/false eyes, straight three, bent three, bulky five, rectangular six, plum six, grape six, board six, golden chicken, carpenter square, L group, seki, ko life, semeai, under the stones, throw-in, second-line hane, snapback eye shape, nakade, bent four in the corner. +- Snapback, throw-in, connect-and-die, probe-before-sacrifice, ladder, net, squeeze, wedge, crosscut, cut, clamp, peep, oil-rat endgame, monkey jump, hane at head, double atari, geta, sacrifice cut. + +## Matching Rules + +KataGo remains the factual judge. The knowledge system only explains and transfers the lesson. + +The match engine ranks results using: + +- Explicit user intent: joseki / life-death / tesuji words in the prompt. +- Position facts: move number, phase, region, current move, candidate moves, PV, and loss. +- Local features: corner, side, 3-3, 4-4, 3-4, first/second/third/fourth line, contact, jump, eye-shape. +- Local geometry: board snapshots are compared around candidate / played anchors with rotation and mirror normalization. +- Liberty / empty-point constraints: the candidate anchor must still be empty, and adjacent colors, edge contact, neighboring groups, and approximate liberties are scored. +- Exact evidence: catalog sequence overlap, answer move overlap, played move, candidate move, and PV overlap. + +Important quality rules: + +- Exact/strong matches may be described as a named joseki, life-death type, or tesuji type. +- Partial matches must be described as "similar to this type". +- Weak matches should not be used as the main teaching point. +- Joseki matches are suppressed when the prompt clearly asks for tactical reading or tesuji. +- Training problem recommendations ignore broad tags such as corner, direction, joseki, life-death, and tesuji; they require specific tags such as snapback, true/false eyes, bent three, or ladder. +- Geometry evidence is used only when at least three local stones match inside the analysis window. Color-swapped matches are allowed but scored lower. +- Geometry matches with weak liberty / empty-point compatibility are filtered out even if the visible stones look similar. + +## Regression Scenarios + +The automated matching smoke test covers: + +- Star point 3-3: exact joseki matches rank first. +- True/false eye: exact life-and-death match ranks before broad corner patterns. +- Snapback: exact tesuji match ranks before broad joseki or generic corner life-and-death patterns. +- Named coverage: avalanche, plum six, and connect-and-die direct user prompts match their intended joseki / life-death / tesuji entries. +- Rotation / mirror coverage: a true/false-eye local shape rotated into a different board area still matches the intended life-and-death type through `geometry:*` and `liberties:*` evidence. + +## Runtime Wiring + +Current-move analysis now builds a board snapshot from the SGF main line immediately before the selected move. Captures are simulated before the snapshot is sent to the knowledge matcher, so stale captured stones should not pollute the local shape window and candidate anchors stay empty. + +Whole-game and recent-game reviews also attach the board snapshot for the largest-loss issue when an SGF record is available. This lets the teacher match dead/alive and tesuji patterns from the actual local position instead of relying only on tags such as "problem move" or "corner". + +## Next Content Expansion + +Do not bulk-import copyrighted problem collections. Future additions should be original common-pattern reconstructions with sourceKind `common-pattern`. + +Prioritized next joseki additions: + +- More star point double-approach sub-branches with whole-board direction labels. +- More komoku avalanche / taisha follow-up branches with explicit "avoid complexity" and "fight" alternatives. +- 3-4 high pincer and low pincer modern AI branches. + +Prioritized next life-and-death additions: + +- Tripod group and more plum-six / grape-six variants with different liberties. +- More edge shortage-of-liberties examples with distinct answer coordinates. +- Semeai examples with shared liberties and ko threats separated. + +Prioritized next tesuji additions: + +- More connect-and-die / connection shortage variants. +- More probe-before-sacrifice sequences in center fighting. +- More geta/net examples with distinct local coordinates. + +Before adding another large batch, continue improving geometric matching with explicit empty-point masks and local ownership features. The current matcher already handles rotation / mirror normalization plus approximate liberties; the next quality jump is to distinguish "same stones and similar liberties, but different vital empty points". diff --git a/docs/MACOS_SIGNING_NOTARIZATION.md b/docs/MACOS_SIGNING_NOTARIZATION.md new file mode 100644 index 0000000..762145e --- /dev/null +++ b/docs/MACOS_SIGNING_NOTARIZATION.md @@ -0,0 +1,58 @@ +# macOS Signing and Notarization + +GoMentor public beta builds must be signed with a Developer ID Application certificate and notarized by Apple before publishing. + +## Required Inputs + +Use CI secrets or local environment variables. Do not commit certificates or passwords. + +- `CSC_LINK`: base64 `.p12` content or a secure URL to the certificate. +- `CSC_KEY_PASSWORD`: password for the certificate. +- `APPLE_API_KEY`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`: preferred notarization credentials. +- Or `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`. +- Or `APPLE_KEYCHAIN`, `APPLE_KEYCHAIN_PROFILE`. + +## Builder Configuration + +`package.json` enables: + +- `hardenedRuntime: true` +- `gatekeeperAssess: false` +- `entitlements: build/entitlements.mac.plist` +- `entitlementsInherit: build/entitlements.mac.inherit.plist` +- `notarize: true` + +The bundled KataGo executable and dylibs live inside `Contents/Resources/data/katago`. They must be present before `pnpm dist:mac` so electron-builder can include them in the signed app bundle. + +## Local Build + +```bash +pnpm install +node scripts/prepare_katago_assets.mjs +node scripts/check_katago_assets.mjs --mode=release +pnpm dist:mac +``` + +## Verification + +```bash +codesign --verify --deep --strict --verbose=2 "release/0.2.0-beta.1/mac-arm64/GoMentor.app" +codesign --verify --deep --strict --verbose=2 "release/0.2.0-beta.1/mac/GoMentor.app" +spctl --assess --type execute --verbose "release/0.2.0-beta.1/mac-arm64/GoMentor.app" +hdiutil verify "release/0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-arm64.dmg" +hdiutil verify "release/0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-x64.dmg" +``` + +If notarization is not handled automatically by electron-builder, submit and staple manually: + +```bash +xcrun notarytool submit "release/0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-arm64.dmg" --keychain-profile "$APPLE_KEYCHAIN_PROFILE" --wait +xcrun stapler staple "release/0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-arm64.dmg" +xcrun stapler validate "release/0.2.0-beta.1/GoMentor-0.2.0-beta.1-mac-arm64.dmg" +``` + +Repeat for the x64 DMG. + +## Current Blocker + +If Developer ID credentials are not configured, macOS builds are internal unsigned/ad-hoc beta artifacts only. Do not tag `v0.2.0-beta.1` for public release until signing and notarization evidence is recorded. diff --git a/docs/P0_BETA_PR_DESCRIPTION.md b/docs/P0_BETA_PR_DESCRIPTION.md new file mode 100644 index 0000000..7107a1d --- /dev/null +++ b/docs/P0_BETA_PR_DESCRIPTION.md @@ -0,0 +1,39 @@ +# P0 Beta PR Description Draft + +## Summary + +This PR productizes GoMentor P0 into a macOS arm64/x64 and Windows x64 beta candidate Go teacher workbench. + +It adds: + +- Startup diagnostics gate +- KataGo asset manifest and release asset checks +- OpenAI-compatible Claude proxy provider +- SGF import student binding +- Fox nickname profile creation/reuse +- Local knowledge cards +- Structured teacher runtime and teacher cards +- Board UI v2, winrate timeline v2, candidate tooltip, key move navigation +- P0 beta and release candidate smoke checks +- Release blocker gates for signing, Windows smoke, and visual QA evidence + +## Verification + +- `pnpm install` +- `pnpm typecheck` +- `pnpm test` +- `pnpm build` +- `pnpm check` +- `node scripts/check_katago_assets.mjs --mode=dev` +- `node scripts/p0_beta_acceptance.mjs` +- `node scripts/package_artifact_smoke.mjs --mode=dev` +- `node scripts/p0_release_candidate_check.mjs --mode=dev` +- `node scripts/verify_release_artifacts.mjs --mode=dev` + +## Known limitations before public release + +- Real KataGo binary/model must be prepared in release packaging; large assets are not committed through normal Git. +- Windows ARM64 is not supported in `v0.2.0-beta.1`. +- Windows/macOS manual installer smoke test is still required. +- macOS signing/notarization and Windows signing evidence are still required before public release. +- Visual QA screenshots are still required before tagging beta release. diff --git a/docs/P0_PR_REVIEW_TEMPLATE.md b/docs/P0_PR_REVIEW_TEMPLATE.md new file mode 100644 index 0000000..024273a --- /dev/null +++ b/docs/P0_PR_REVIEW_TEMPLATE.md @@ -0,0 +1,35 @@ +# P0 Productization PR Review Template + +## Summary + +This PR productizes GoMentor P0: diagnostics, embedded KataGo asset strategy, Claude-compatible provider, student profiles, local knowledge cards, teacher runtime, upgraded board UI, and Beta readiness checks. + +## Must-pass checks + +- [ ] `pnpm install` +- [ ] `pnpm typecheck` +- [ ] `pnpm test` +- [ ] `pnpm build` +- [ ] `pnpm check` +- [ ] `node scripts/check_katago_assets.mjs --mode=dev` +- [ ] `node scripts/p0_beta_acceptance.mjs` + +## Manual QA + +- [ ] First-launch diagnostics gate works. +- [ ] SGF import can bind a student. +- [ ] Fox nickname sync creates/reuses a student profile. +- [ ] Current move analysis works. +- [ ] Full game analysis works. +- [ ] Recent 10 games analysis writes profile data. +- [ ] Teacher card key-move actions jump to the board. +- [ ] Candidate tooltip works. +- [ ] Tool log is folded by default. + +## Release risk + +- [ ] KataGo binary/model delivery strategy verified. +- [ ] Windows package smoke checked. +- [ ] macOS package smoke checked. +- [ ] No secrets committed. +- [ ] No accidental zip/node_modules/out/release committed. diff --git a/docs/P0_STATUS.md b/docs/P0_STATUS.md new file mode 100644 index 0000000..3bb7217 --- /dev/null +++ b/docs/P0_STATUS.md @@ -0,0 +1,44 @@ +# P0 Status After Sprint 2 Package + +## Done by Sprint 1 feedback + +- Diagnostics foundation +- LLM provider foundation +- Student profile foundation +- Knowledge cards foundation +- Teacher runtime foundation +- Initial renderer wiring +- Build/typecheck/test/check passing + +## Added by Sprint 2 package + +- KataGo manifest and asset scripts +- Asset inspection service +- Diagnostics gate component +- SGF/Fox student binding helpers +- Structured teacher parser +- Tool log builder +- Student binding dialog +- Student rail card +- Runtime asset panel +- Teacher result card +- Release workflow snippets + +## Still P0 after Sprint 2 + +- Codex must wire snippets into actual App and main IPC. +- Real KataGo binary/model must be provided by release pipeline or Git LFS. +- End-to-end SGF import flow must trigger binding UI. +- Fox sync response must return bound student. +- Teacher runtime must pass structuredResult to renderer. +- Full Windows/macOS packaging smoke test must run locally or CI. + +## Next sprint + +Sprint 3 should focus on board and chat UI polish: + +- Board texture and stone rendering +- Candidate move visuals +- Key mistake markers +- Winrate graph interaction +- AI-editor style teacher panel diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index b0a238b..5c9fe4d 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -1,6 +1,6 @@ # Packaging -KataSensei uses `electron-builder` for desktop packaging. +GoMentor uses `electron-builder` for desktop packaging. ## Local Commands @@ -21,16 +21,18 @@ release// The release workflow runs on semver tags: ```bash -git tag v0.1.0 -git push origin v0.1.0 +git tag v0.2.0-beta.1 +git push origin v0.2.0-beta.1 ``` The workflow builds on native runners: - macOS: DMG and ZIP. -- Windows: NSIS installer and portable EXE. +- Windows: x64 NSIS installer and x64 portable ZIP for P0 beta. - Linux: AppImage, DEB, and tar.gz. +Windows ARM64 is not supported in `v0.2.0-beta.1`. + ## KataGo Runtime Large KataGo binaries and models are not committed to Git. Packagers can place runtime files under: @@ -49,5 +51,7 @@ The public workflow currently disables automatic code-signing discovery. Before - Configure Apple Developer ID signing and notarization. - Configure Windows code signing. +- Complete Windows 11 x64 smoke testing. +- Complete visual QA evidence. - Decide the update channel and release cadence. - Verify downloaded KataGo models with checksums. diff --git a/docs/RC_RELEASE_GUIDE.md b/docs/RC_RELEASE_GUIDE.md new file mode 100644 index 0000000..6cc1981 --- /dev/null +++ b/docs/RC_RELEASE_GUIDE.md @@ -0,0 +1,111 @@ +# GoMentor v0.2.0-beta.1 P0 Release Candidate Guide + +## 1. 目标 + +P0 RC 的目标不是功能扩张,而是验证: + +1. 用户能安装应用。 +2. 应用能自检 KataGo / Claude 兼容代理 / 用户目录。 +3. 用户能导入 SGF。 +4. 用户能输入野狐昵称同步棋谱。 +5. 当前手、整盘、最近 10 局分析能跑通。 +6. 学生画像能根据野狐昵称持续更新。 +7. macOS arm64/x64 和 Windows x64 均可用。 + +## 2. 发布前必做 + +```bash +pnpm install +pnpm typecheck +pnpm test +pnpm build +pnpm check +node scripts/check_katago_assets.mjs --mode=dev +node scripts/p0_beta_acceptance.mjs +node scripts/package_artifact_smoke.mjs --mode=dev +node scripts/p0_release_candidate_check.mjs --mode=dev +node scripts/verify_release_artifacts.mjs --mode=dev +node scripts/collect_release_evidence.mjs --mode=dev +``` + +## 3. 准备真实 KataGo assets + +P0 策略是不把大型 binary/model 普通 Git 提交。 + +Release 前必须在 runner 或本地准备: + +```text +data/katago/bin/darwin-arm64/katago +data/katago/bin/darwin-x64/katago +data/katago/bin/win32-x64/katago.exe +data/katago/models/.bin.gz +``` + +Windows ARM64 暂不支持。不要发布 `win-arm64` 安装包,除非后续补齐 `data/katago/bin/win32-arm64/katago.exe`、manifest 支持和 release 检查。 + +然后运行: + +```bash +node scripts/check_katago_assets.mjs --mode=release +node scripts/p0_release_candidate_check.mjs --mode=release +``` + +## 4. 生成安装包 + +macOS: + +```bash +pnpm dist:mac +node scripts/verify_release_artifacts.mjs --mode=release +``` + +Windows: + +```bash +pnpm dist:win +node scripts/verify_release_artifacts.mjs --mode=release +``` + +P0 beta Windows 产物应为: + +```text +GoMentor-0.2.0-beta.1-win-x64.exe +GoMentor-0.2.0-beta.1-win-x64-portable.zip +``` + +`win-arm64` 产物在 P0 beta 阶段视为发布阻塞项。 + +## 5. 人工 Smoke + +详见: + +- `docs/RELEASE_SMOKE_MATRIX.md` +- `docs/VISUAL_QA_CAPTURE_GUIDE.md` +- `docs/KATAGO_RELEASE_ASSETS_CHECKLIST.md` +- `docs/WINDOWS_SMOKE_TEST.md` +- `docs/VISUAL_QA_EVIDENCE_TEMPLATE.md` + +## 6. Release Candidate 命名 + +建议: + +```text +v0.2.0-beta.1 +``` + +## 7. 不要发布的情况 + +出现以下任一情况不要发布: + +- Windows 或 macOS 无法启动。 +- KataGo release asset 缺失。 +- Windows ARM64 产物被生成或上传。 +- macOS 未签名/未公证且准备公开发布。 +- Windows 安装包未签名且准备公开发布。 +- Windows 11 x64 真机 smoke 未完成。 +- 视觉 QA evidence 未完成。 +- SGF 导入后无法打开棋局。 +- 野狐昵称同步失败且没有可理解错误。 +- 老师分析输出为空或无法展示。 +- 学生画像无法写入。 +- 诊断页显示 blocked 但用户仍可进入分析主链路。 diff --git a/docs/RELEASE_BETA_CHECKLIST.md b/docs/RELEASE_BETA_CHECKLIST.md new file mode 100644 index 0000000..fd80f59 --- /dev/null +++ b/docs/RELEASE_BETA_CHECKLIST.md @@ -0,0 +1,85 @@ +# GoMentor v0.2.0-beta.1 P0 Beta Release Checklist + +## 1. 必备检查 + +- [ ] `pnpm install` 通过 +- [ ] `pnpm typecheck` 通过 +- [ ] `pnpm test` 通过 +- [ ] `pnpm build` 通过 +- [ ] `pnpm check` 通过 +- [ ] `node scripts/check_katago_assets.mjs --mode=release` 通过 +- [ ] `node scripts/p0_beta_acceptance.mjs` 通过 +- [ ] `node scripts/package_artifact_smoke.mjs --mode=release` 通过 +- [ ] `node scripts/p0_release_candidate_check.mjs --mode=release` 通过,且人工 gate 结论已记录 +- [ ] `node scripts/verify_release_artifacts.mjs --mode=release` 通过 + +## 2. macOS + +- [ ] arm64 安装包可启动 +- [ ] x64 安装包可启动 +- [ ] DMG 通过 `hdiutil verify` +- [ ] App 通过 `codesign --verify --deep --strict` +- [ ] DMG 通过 notarization 和 stapler validate +- [ ] 内置 KataGo 可执行 +- [ ] 默认模型可读 +- [ ] 首启诊断 ready 或 warning 可解释 +- [ ] SGF 导入可用 +- [ ] 野狐昵称同步可用 +- [ ] 当前手分析可用 +- [ ] 整盘分析可用 +- [ ] 最近 10 局分析可用 + +## 3. Windows + +- [ ] x64 安装包可启动 +- [ ] 没有生成或上传 win-arm64 产物 +- [ ] 安装包签名通过 `signtool verify`,或明确标记 unsigned/internal beta +- [ ] 中文路径下可启动 +- [ ] 内置 KataGo 可执行 +- [ ] 默认模型可读 +- [ ] 用户数据目录可写 +- [ ] SGF 导入可用 +- [ ] 野狐昵称同步可用 +- [ ] Claude 兼容代理测试可用 +- [ ] 当前手/整盘/最近 10 局分析可用 + +## 4. UI 验收 + +- [ ] `docs/VISUAL_QA_EVIDENCE_TEMPLATE.md` 已填写 +- [ ] 棋盘不再像开发工具 +- [ ] 候选点不遮挡主要棋子 +- [ ] 候选点 tooltip 清楚 +- [ ] 老师卡片结构清楚 +- [ ] 关键手能跳转 +- [ ] 工具日志默认折叠 +- [ ] 错误提示有用户可读文案 + +## 5. 发布文档 + +- [ ] README 当前状态更新 +- [ ] `docs/KATAGO_ASSETS.md` 完整 +- [ ] `docs/DIAGNOSTICS.md` 完整 +- [ ] `docs/TEACHER_RUNTIME.md` 完整 +- [ ] `docs/MACOS_SIGNING_NOTARIZATION.md` 完整 +- [ ] `docs/WINDOWS_CODE_SIGNING.md` 完整 +- [ ] `docs/WINDOWS_SMOKE_TEST.md` 完整 +- [ ] Release notes 写明 Beta 限制 + +## 6. Public Beta Gate + +- [ ] `automationReady=true` +- [ ] `assetsReady=true` +- [ ] `installersReady=true` +- [ ] `signingReady=true` +- [ ] `windowsSmokeReady=true` +- [ ] `visualQaReady=true` +- [ ] `publicBetaReady=true` + +## 7. 发布前禁止项 + +- [ ] 没有 token/API key +- [ ] 没有 `.env` +- [ ] 没有 zip 包误提交 +- [ ] 没有 node_modules/out/release 误提交 +- [ ] 没有未经策略确认的大模型/大二进制普通 Git 提交 +- [ ] 没有直接发布 Windows ARM64 产物 diff --git a/docs/RELEASE_NOTES_v0.2.0-beta.1.md b/docs/RELEASE_NOTES_v0.2.0-beta.1.md new file mode 100644 index 0000000..d17aadf --- /dev/null +++ b/docs/RELEASE_NOTES_v0.2.0-beta.1.md @@ -0,0 +1,35 @@ +# GoMentor v0.2.0-beta.1 Release Notes + +## P0 Beta Scope + +- Three-column Go study workbench with library, KTrain/Lizzie-inspired board, winrate timeline, and AI teacher panel. +- Bundled KataGo asset strategy with macOS arm64/x64 and Windows x64 paths. +- Default KataGo b18 recommended model. +- OpenAI-compatible multimodal LLM provider settings. +- Current-move, full-game, recent-10-game, and training-plan teacher workflows. +- Local P0 knowledge cards and long-term student profile storage. +- Diagnostics gate, asset inspection, release readiness panel, and release evidence scripts. + +## Supported Beta Platforms + +- macOS arm64. +- macOS x64. +- Windows 11 x64. + +Windows ARM64 is not supported in this beta. Do not publish `win-arm64` installers unless `data/katago/bin/win32-arm64/katago.exe`, manifest support, and release checks are added. + +## Known Issues + +- macOS packages must be Developer ID signed and notarized before public beta distribution. +- Windows installers should be code signed. Unsigned Windows builds are internal beta only and may trigger SmartScreen warnings. +- Windows real-machine smoke and visual QA evidence are manual release blockers before tagging. +- Linux packaging may build, but P0 beta acceptance is focused on macOS and Windows. + +## Before Tagging + +- `pnpm check` passes. +- `node scripts/check_katago_assets.mjs --mode=release` passes in the release asset layout. +- `node scripts/verify_release_artifacts.mjs --mode=release` passes for `0.2.0-beta.1`. +- macOS signing/notarization evidence is recorded. +- Windows signing and Windows 11 x64 smoke evidence are recorded. +- Visual QA evidence is recorded. diff --git a/docs/RELEASE_SMOKE_MATRIX.md b/docs/RELEASE_SMOKE_MATRIX.md new file mode 100644 index 0000000..fab5d51 --- /dev/null +++ b/docs/RELEASE_SMOKE_MATRIX.md @@ -0,0 +1,58 @@ +# Release Smoke Matrix + +## macOS Apple Silicon + +| 项目 | 结果 | 备注 | +|---|---|---| +| dmg 可打开 | | | +| 应用可拖入 Applications | | | +| 首次启动成功 | | | +| 诊断页显示 KataGo ready | | | +| 中文路径用户目录可写 | | | +| SGF 导入成功 | | | +| 当前手分析成功 | | | +| 整盘分析成功 | | | +| Claude 兼容代理图片测试成功 | | | +| 退出重启后棋谱仍在 | | | + +## macOS Intel + +| 项目 | 结果 | 备注 | +|---|---|---| +| zip/dmg 可启动 | | | +| 内置 KataGo x64 可执行 | | | +| SGF 导入成功 | | | +| 当前手分析成功 | | | + +## Windows x64 + +| 项目 | 结果 | 备注 | +|---|---|---| +| installer 可运行 | | | +| 应用可启动 | | | +| 中文用户名路径可写 | | | +| 内置 katago.exe 可执行 | | | +| 默认模型可读取 | | | +| SGF 导入成功 | | | +| 野狐昵称同步成功或有明确错误 | | | +| 当前手分析成功 | | | +| 整盘分析成功 | | | +| 最近 10 局分析成功 | | | +| 学生画像重启后保留 | | | + +## Windows ARM64 + +P0 beta 不支持 Windows ARM64。若 release 目录或 GitHub Release 中出现 `win-arm64` 产物,视为发布阻塞项。 + +## 失败记录模板 + +```text +平台: +版本: +安装包: +步骤: +预期: +实际: +截图/日志: +是否阻塞发布:是/否 +``` diff --git a/docs/SPRINT1_DELIVERY.md b/docs/SPRINT1_DELIVERY.md new file mode 100644 index 0000000..2dc0d38 --- /dev/null +++ b/docs/SPRINT1_DELIVERY.md @@ -0,0 +1,32 @@ +# Sprint 1 Delivery + +## 已交付 + +- 启动诊断模块 +- 用户可读错误码 +- Claude 兼容代理 provider 抽象 +- 学生画像基础服务 +- 本地知识卡 schema、检索和 48 张 P0 教学卡 +- 老师结构化结果 schema 与 prompt builder +- Renderer 诊断页、设置面板、老师结果卡片 +- smoke check 脚本 + +## 需要合并的现有文件 + +1. `src/main/index.ts` + - 加 IPC:`diagnostics:get`、`students:list`、`students:resolve-fox`、`students:attach-game`、`knowledge:search` +2. `src/preload/index.ts` + - 暴露上述方法 +3. `src/main/services/llm.ts` + - 改为调用 `postOpenAICompatibleChat` 和 `probeOpenAICompatibleProvider` +4. `src/renderer/src/App.tsx` + - 启动时先拿 diagnostics,blocked 时显示 `DiagnosticsPanel` +5. `src/renderer/src/styles.css` + - import / 合并 diagnostics、teacher-result 样式 + +## 下一批开发 + +- SGF 导入后绑定学生 +- 野狐同步后自动 resolve 学生画像 +- Teacher runtime 接入知识库和画像 +- 棋盘 UI 视觉重构 diff --git a/docs/SPRINT2_DELIVERY.md b/docs/SPRINT2_DELIVERY.md new file mode 100644 index 0000000..bb7b72a --- /dev/null +++ b/docs/SPRINT2_DELIVERY.md @@ -0,0 +1,51 @@ +# Sprint 2 Delivery Notes + +## 目标 + +把 P0 从“基础设施已合入”推进到“用户级交付闭环”:启动诊断能 gate 工作台、SGF/野狐能绑定学生画像、Teacher runtime 能稳定输出结构化老师结果、KataGo 内置资源策略可以进入打包验收。 + +## 新增模块 + +### KataGo Assets + +- `data/katago/manifest.json` +- `scripts/prepare_katago_assets.mjs` +- `scripts/check_katago_assets.mjs` +- `src/main/services/katago/katagoAssets.ts` + +### Library / Student Binding + +- `src/main/services/library/gameIdentity.ts` +- `src/main/services/library/studentBinding.ts` + +### Teacher Runtime Helpers + +- `src/main/services/teacher/structuredResultParser.ts` +- `src/main/services/teacher/toolLogBuilder.ts` + +### Renderer Components + +- `DiagnosticsGate.tsx` +- `StudentBindingDialog.tsx` +- `StudentRailCard.tsx` +- `KataGoAssetsPanel.tsx` +- `TeacherRunCard.tsx` + +## 本包不做的事 + +- 不提交实际 KataGo binary/model。 +- 不直接修改 GitHub 远端。 +- 不强制替换现有 `App.tsx`。 +- 不一次性重做棋盘视觉;棋盘高级化放 Sprint 3。 + +## 验收 + +Sprint 2 合入后至少应满足: + +- diagnostics gate 能阻止 blocked 状态进入主界面。 +- LLM 未配置不 blocked,但老师按钮提示配置代理。 +- SGF 导入后可绑定学生。 +- 野狐昵称同步可自动创建/复用学生画像。 +- 最近 10 局分析按 studentId 取数据。 +- Teacher 输出可结构化渲染,LLM markdown 可 fallback。 +- KataGo 资源缺失时 dev warning,release fail-fast。 diff --git a/docs/SPRINT3_DELIVERY.md b/docs/SPRINT3_DELIVERY.md new file mode 100644 index 0000000..17cb15c --- /dev/null +++ b/docs/SPRINT3_DELIVERY.md @@ -0,0 +1,43 @@ +# Sprint 3 Delivery: Board + Teacher UX Upgrade + +## 已提供的新增文件 + +### Board UX + +- `src/renderer/src/features/board/boardGeometry.ts` +- `src/renderer/src/features/board/GoBoardV2.tsx` +- `src/renderer/src/features/board/WinrateTimelineV2.tsx` +- `src/renderer/src/features/board/BoardInsightPanel.tsx` +- `src/renderer/src/features/board/board-v2.css` + +### Teacher UX + +- `src/renderer/src/features/teacher/TeacherRunCardPro.tsx` +- `src/renderer/src/features/teacher/TeacherComposerPro.tsx` +- `src/renderer/src/features/teacher/teacher-pro.css` + +### Design System + +- `src/renderer/src/styles/design-tokens.css` + +### Tests / Contracts + +- `tests/sprint3-ui-contract.test.mjs` + +## Sprint 3 验收目标 + +1. 棋盘视觉不再像开发调试图,而像正式围棋分析产品。 +2. 推荐点可以清楚表达主推荐、次推荐、胜率、目差和访问数。 +3. 当前手、最后一手、关键问题手能被用户一眼看到。 +4. 胜率图能支持当前手和关键问题手定位。 +5. 老师区输出不再是一大段 markdown,而是结构化卡片。 +6. 工具日志默认折叠,出错时能定位阶段。 +7. UI 仍保持原三栏结构,不破坏 Sprint 1/2 的主链路。 + +## 本轮不做 + +- 不提交 KataGo binary/model。 +- 不重写主进程 Teacher Runtime。 +- 不新增大依赖。 +- 不做完整题库系统。 +- 不改发布策略。 diff --git a/docs/SPRINT4_DELIVERY.md b/docs/SPRINT4_DELIVERY.md new file mode 100644 index 0000000..3604478 --- /dev/null +++ b/docs/SPRINT4_DELIVERY.md @@ -0,0 +1,49 @@ +# Sprint 4 Delivery + +## 目标 + +Sprint 4 聚焦 P0 Beta 发布前闭环:交互验收、关键手跳转、打包 smoke、视觉验收和 release readiness。 + +## 新增内容 + +### Renderer + +- `CandidateTooltip`:候选点悬停详情。 +- `KeyMoveNavigator`:关键手列表与跳转动作。 +- `TeacherKeyMoveActions`:老师卡片内关键手动作按钮。 +- `BetaAcceptancePanel`:设置页/诊断页展示 P0 Beta readiness。 +- `timelineInteraction.ts`:胜率图拖拽/定位纯函数。 + +### Main + +- `release/readiness.ts`:检查 P0 交付状态的服务草案。 + +### Scripts + +- `p0_beta_acceptance.mjs`:静态验收脚本。 +- `package_artifact_smoke.mjs`:打包产物 smoke check。 + +### Tests + +- `sprint4-release-contract.test.mjs`:检查 Sprint 4 合同文件与关键导出。 + +### Docs + +- `RELEASE_BETA_CHECKLIST.md` +- `VISUAL_QA_CHECKLIST.md` +- `P0_PR_REVIEW_TEMPLATE.md` + +## 不做什么 + +- 不提交 KataGo binary/model。 +- 不重写后端主链路。 +- 不删除旧组件 fallback。 +- 不把 Electron 自动更新做成 P0 阻塞项。 + +## Sprint 4 完成标准 + +- 老师卡片能跳转关键手。 +- 棋盘候选点能显示 tooltip。 +- 胜率图定位逻辑有测试。 +- P0 Beta 脚本能跑出清晰结果。 +- Windows/macOS release readiness 文档齐全。 diff --git a/docs/SPRINT5_DELIVERY.md b/docs/SPRINT5_DELIVERY.md new file mode 100644 index 0000000..166f7fd --- /dev/null +++ b/docs/SPRINT5_DELIVERY.md @@ -0,0 +1,53 @@ +# Sprint 5 Delivery + +## Sprint 5 目标 + +Sprint 5 不继续扩功能,而是把 P0 分支推到可开 PR、可做安装包实测、可发布 Release Candidate 的状态。 + +## 本包交付 + +### Scripts + +- `scripts/p0_release_candidate_check.mjs` + - 检查 package scripts、builder 资源配置、workflow、关键源码、知识库、文档、资源策略。 +- `scripts/verify_release_artifacts.mjs` + - 检查 `release/` 下 macOS / Windows 安装包是否存在,dev 模式允许无产物。 +- `scripts/collect_release_evidence.mjs` + - 采集当前 commit、package、检查结果、资源状态,输出到 `release-evidence/`。 + +### GitHub + +- `.github/workflows/p0-release-candidate.yml` + - 对 P0 分支和 PR 执行 RC readiness 检查。 +- `.github/PULL_REQUEST_TEMPLATE/p0-beta.md` + - P0 Beta PR 模板。 + +### Docs + +- `docs/RC_RELEASE_GUIDE.md` +- `docs/RELEASE_SMOKE_MATRIX.md` +- `docs/VISUAL_QA_CAPTURE_GUIDE.md` +- `docs/KATAGO_RELEASE_ASSETS_CHECKLIST.md` +- `docs/P0_BETA_PR_DESCRIPTION.md` + +### Renderer + +- `ReleaseReadinessPanel.tsx` +- `release-readiness.css` + +该组件是轻量展示组件,Codex 合入时可以接到 Settings Drawer 或 BetaAcceptancePanel 旁边。 + +## 本轮不做 + +- 不提交 KataGo 大二进制和模型。 +- 不生成真实安装包。 +- 不直接创建 GitHub Release。 +- 不合并 main。 + +## Sprint 5 完成后建议 + +- 创建 PR:`feature/p0-productization` → `main` +- 在 PR 内跑 P0 RC workflow +- 在本地/CI 准备真实 KataGo assets +- 分别在 Windows/macOS 生成安装包并人工 smoke test +- 若通过,打 `v0.2.0-beta.1` diff --git a/docs/STUDENT_PROFILE.md b/docs/STUDENT_PROFILE.md new file mode 100644 index 0000000..aa6c8b8 --- /dev/null +++ b/docs/STUDENT_PROFILE.md @@ -0,0 +1,36 @@ +# Student Profile Design + +## Principle + +The product-facing identity is the Fox nickname, but storage should use a stable `studentId`. + +## SGF flow + +SGF does not always include a Fox nickname. After SGF import, ask the user who the student is: + +- Black +- White +- Existing student +- New student +- Skip binding + +If the SGF player name matches an existing alias, suggest that student. + +## Fox flow + +Fox nickname sync should automatically create or reuse the matching student profile. All imported games from that sync should bind to the student. + +## Profile updates + +Teacher runtime should write back: + +- error types +- recurring patterns +- training focus +- recent analyzed game ids + +## Avoid + +- Do not create duplicate students for the same Fox nickname. +- Do not silently merge fuzzy matches without user confirmation. +- Do not prevent analysis if no student is bound; just skip profile writeback. diff --git a/docs/TEACHER_AGENT.md b/docs/TEACHER_AGENT.md index befa2ab..41e57f0 100644 --- a/docs/TEACHER_AGENT.md +++ b/docs/TEACHER_AGENT.md @@ -1,6 +1,6 @@ -# KataSensei Teacher Agent +# GoMentor Teacher Agent -KataSensei treats the right-side coach as an agent, not a static chat panel. The agent can plan a task, call local tools, inspect results, and write a student profile that improves later reviews. +GoMentor treats the right-side coach as an agent, not a static chat panel. The agent can plan a task, call local tools, inspect results, and write a student profile that improves later reviews. The agent must not be product-limited to a small set of button workflows. Current-move analysis, recent-game review, and training-plan generation are only fast paths. Any natural-language request can enter the open-ended agent path, where the teacher receives the tool catalog, current game context, student profile, knowledge cards, and optional web-search snippets, then decides the right output shape. @@ -18,7 +18,7 @@ If these sources disagree, KataGo wins. - Do not artificially restrict the teacher to "current move", "recent games", or "training plan". - Treat every tool as part of one extensible capability registry. - Let the teacher plan multi-step work from the user's actual goal, then choose tools by need. -- Environment setup is also a teacher capability: the teacher may detect local KataGo, write KataSensei app config, and verify that analysis works. +- Environment setup is also a teacher capability: the teacher may detect local KataGo, write GoMentor app config, and verify that analysis works. - Show every tool call in the chat log so the user can see what happened. - Add new tools by registering their capability and privacy notes; do not fork the agent into more hard-coded flows. - Privacy and local-system safety are guardrails, not product limitations: do not send student names, SGF contents, screenshots, API keys, or local paths to web search. Destructive actions, installation, and OS-level security changes should be surfaced as explicit tool steps. @@ -30,13 +30,13 @@ If these sources disagree, KataGo wins. - `katago.analyzePosition`: compare the current move with KataGo recommendations. - `katago.analyzeGameBatch`: run local batch analysis without per-game LLM calls. - `system.detectEnvironment`: inspect local KataGo, model/config paths, local LLM proxy, and available models. -- `settings.writeAppConfig`: update KataSensei settings so the teacher can use detected local tools immediately. +- `settings.writeAppConfig`: update GoMentor settings so the teacher can use detected local tools immediately. - `katago.verifyAnalysis`: run a low-visit KataGo analysis to verify binary/config/model compatibility. - `board.captureTeachingImage`: create a board-only PNG with current move and recommendation marks. - `knowledge.searchLocal`: retrieve structured cards from `data/knowledge`. - `web.searchGoKnowledge`: optional generic web search; never include student names, SGF content, or board screenshots. - `studentProfile.read/write`: persist long-term learning signals. -- `report.saveAnalysis`: write reports under `~/.katasensei/teacher-reports`. +- `report.saveAnalysis`: write reports under `~/.gomentor/teacher-reports`. ## Open-Ended Agent Path @@ -56,7 +56,7 @@ This is intentionally close to Claude Code's model: the teacher is a task-runnin - LLM API keys are stored through Electron `safeStorage` when available. - Renderer receives only whether an API key exists, never the saved key itself. -- Opening files through IPC is restricted to KataSensei-managed directories. +- Opening files through IPC is restricted to GoMentor-managed directories. - Current-move analysis sends the board PNG, KataGo JSON, and selected knowledge cards to the configured multimodal LLM. - Batch analysis disables per-game LLM calls and performs one final teacher summary. - Open-ended tasks may call the LLM with the tool catalog, student profile, SGF metadata/recent moves, local knowledge cards, and generic web-search titles. diff --git a/docs/TEACHER_RUNTIME.md b/docs/TEACHER_RUNTIME.md new file mode 100644 index 0000000..4308304 --- /dev/null +++ b/docs/TEACHER_RUNTIME.md @@ -0,0 +1,45 @@ +# Teacher Runtime v1 + +## Task types + +- current move +- full game +- recent 10 games +- freeform follow-up + +## Required context + +Every high-quality teacher call should include: + +- Game context +- KataGo evidence +- Knowledge cards +- Student profile +- User prompt + +## Structured output + +Ask the Claude-compatible model to output JSON. Parse JSON if possible. Fall back to markdown if needed. + +Required result fields: + +- headline +- summary +- keyMistakes +- correctThinking +- drills +- followupQuestions +- profileUpdates + +## Tool logs + +Tool logs should be shown collapsed by default. They should include: + +- read game +- KataGo analysis +- knowledge search +- student profile read/write +- LLM call +- report save + +Errors should identify the failing stage, not only show generic “task failed”. diff --git a/docs/UI_ACCEPTANCE_CHECKLIST.md b/docs/UI_ACCEPTANCE_CHECKLIST.md new file mode 100644 index 0000000..f683830 --- /dev/null +++ b/docs/UI_ACCEPTANCE_CHECKLIST.md @@ -0,0 +1,33 @@ +# Sprint 3 UI Acceptance Checklist + +## 棋盘 + +- [ ] 19 路棋盘坐标清晰,且不会污染棋盘中心。 +- [ ] 黑白棋子有明显质感和阴影。 +- [ ] 最后一手在黑棋/白棋上都清晰可见。 +- [ ] 候选点显示顺位、胜率/目差摘要。 +- [ ] 主候选点比次候选点更突出。 +- [ ] 关键问题手可以被高亮。 +- [ ] 没有分析数据时棋盘仍然干净好看。 +- [ ] 小窗口下不溢出。 + +## 胜率图 + +- [ ] 能显示当前手位置。 +- [ ] 能显示关键损失点。 +- [ ] Hover/点击能跳转或至少能通过回调接线。 +- [ ] 没有分析数据时显示空状态。 + +## 老师区 + +- [ ] 老师总结、证据、关键手、训练建议分区展示。 +- [ ] 工具日志默认折叠。 +- [ ] markdown fallback 仍然可读。 +- [ ] 等待态自然,不像卡死。 +- [ ] 追问输入框支持快捷问题。 + +## 总体验 + +- [ ] 首屏视觉中心是棋盘和老师输出。 +- [ ] 状态信息不过度打扰。 +- [ ] 不再像开发调试台。 diff --git a/docs/VISUAL_QA_CAPTURE.md b/docs/VISUAL_QA_CAPTURE.md new file mode 100644 index 0000000..70042df --- /dev/null +++ b/docs/VISUAL_QA_CAPTURE.md @@ -0,0 +1,57 @@ +# GoMentor Visual QA Capture + +Sprint 7 adds an internal UI Gallery for repeatable visual review. It uses mock data only and does not require real KataGo, LLM, Fox sync, or API keys. + +## Open the Gallery + +Start the app in development mode: + +```bash +pnpm dev +``` + +Open either route: + +```text +http://localhost:5173/#/ui-gallery +http://localhost:5173/?ui-gallery=1 +``` + +## Capture Screenshots + +If Playwright is available locally, run: + +```bash +GOMENTOR_UI_GALLERY_URL="http://localhost:5173/#/ui-gallery" \ +node scripts/capture_ui_gallery.mjs +``` + +The script writes screenshots to: + +```text +release-evidence/ui-gallery/ +``` + +Do not commit local screenshot evidence by default. Attach it to the PR or release evidence bundle when doing manual QA. + +## Required Evidence + +- UI Gallery overview +- GoBoardV2 with stones, coordinates, key move markers, and candidate points +- CandidateTooltip +- WinrateTimelineV2 hover/drag state +- KeyMoveNavigator strip +- BoardInsightPanel +- TeacherRunCardPro structured result +- TeacherComposerPro focus and busy states +- StudentRailCard +- SGF StudentBindingDialog +- DiagnosticsGate / DiagnosticsPanel +- Settings readiness / BetaAcceptancePanel +- Empty, error, and loading states + +## Acceptance Notes + +- The app may be runnable as unsigned beta before public release signing is complete. +- `publicBetaReady` must remain false until signing, Windows smoke, and visual QA evidence are complete. +- Visual QA evidence should be attached to a PR comment or local release evidence directory, not committed as large binary churn. diff --git a/docs/VISUAL_QA_CAPTURE_GUIDE.md b/docs/VISUAL_QA_CAPTURE_GUIDE.md new file mode 100644 index 0000000..c94ae8f --- /dev/null +++ b/docs/VISUAL_QA_CAPTURE_GUIDE.md @@ -0,0 +1,26 @@ +# Visual QA Capture Guide + +## 必须截图 + +1. 首启诊断页 ready 状态 +2. 首启诊断页 warning 状态 +3. SGF 导入后的学生绑定弹窗 +4. 野狐昵称同步后的学生卡片 +5. 棋盘默认状态 +6. 当前手分析后的推荐点 +7. 候选点 tooltip +8. 胜率图点击/拖拽后的当前手标尺 +9. 老师结构化结果卡片 +10. 工具日志折叠和展开 +11. 关键手跳转按钮 +12. 设置页 KataGo 资源状态 +13. 设置页 Claude 兼容代理配置 + +## 视觉验收标准 + +- 棋盘不应显得像临时 SVG demo。 +- 推荐点文字不能糊或遮挡严重。 +- tooltip 不应遮挡最后一手和主推荐点。 +- 老师卡片层次要清楚。 +- 工具日志默认不喧宾夺主。 +- 错误提示要像产品文案,不像 console log。 diff --git a/docs/VISUAL_QA_CHECKLIST.md b/docs/VISUAL_QA_CHECKLIST.md new file mode 100644 index 0000000..9f53866 --- /dev/null +++ b/docs/VISUAL_QA_CHECKLIST.md @@ -0,0 +1,91 @@ +# P0 Beta Visual QA Checklist + +Visual QA is manual required before tagging `v0.2.0-beta.1`. Keep screenshots in `release-evidence/` or attach them to the PR/release discussion. Do not commit local screenshots by default. + +Sprint 7 adds a stable mock route for visual QA: + +```text +http://localhost:5173/#/ui-gallery +``` + +See `docs/VISUAL_QA_CAPTURE.md` for automated screenshot capture. + +## 必备截图 + +- [ ] DiagnosticsGate +- [ ] Main workbench +- [ ] GoBoardV2 +- [ ] CandidateTooltip +- [ ] WinrateTimelineV2 +- [ ] BoardInsightPanel +- [ ] TeacherRunCardPro +- [ ] TeacherKeyMoveActions +- [ ] SGF bind dialog +- [ ] Student rail card +- [ ] Settings readiness panel +- [ ] UI Gallery overview +- [ ] Empty/error/loading states +- [ ] Desktop titlebar +- [ ] Command palette +- [ ] Desktop Preferences modal +- [ ] Desktop statusbar + +## 棋盘 + +- [ ] 19 路棋盘完整显示 +- [ ] 棋盘木纹自然,不显脏、不偏亮橙 +- [ ] 坐标清晰但不抢眼 +- [ ] 黑白棋子边缘/阴影自然 +- [ ] 最后一手标记清楚 +- [ ] 第一候选点最突出 +- [ ] 次级候选点克制 +- [ ] 推荐点文字可读 +- [ ] 鼠标悬停显示候选点详情 +- [ ] tooltip 不遮挡当前点 + +## 胜率图 + +- [ ] 当前手竖线准确 +- [ ] 问题手标记清楚 +- [ ] 拖拽/点击定位手数准确 +- [ ] hover tooltip 显示手数、胜率、目差、损失类型 +- [ ] 关键手导航呈现为分析 strip,不像普通按钮列表 +- [ ] 空数据时有清楚占位 +- [ ] 不因为图表重绘导致卡顿明显 + +## 老师区 + +- [ ] 总结卡第一屏可读 +- [ ] 证据卡和训练卡层级清楚 +- [ ] 工具日志默认折叠 +- [ ] 展开工具日志后不破坏布局 +- [ ] 关键手按钮能跳转棋盘 +- [ ] markdown fallback 仍可显示 + +## 诊断/设置 + +- [ ] Ready/Warning/Blocked 视觉区分明确 +- [ ] 缺 LLM 不阻塞基础使用 +- [ ] 缺 KataGo 的提示可理解 +- [ ] KataGo 资源状态展示路径和建议动作 +- [ ] Release readiness 明确显示 publicBetaReady=false,直到签名、Windows smoke、视觉 QA 都完成 + +## 截图建议 + +发布前至少留 11 张截图: + +1. 首启诊断 ready/warning 状态。 +2. 设置页 readiness panel。 +3. 主工作台三栏布局。 +4. SGF 导入并绑定学生。 +5. 野狐昵称同步后的学生卡。 +6. 当前手分析:棋盘 + 老师卡片。 +7. 候选点 tooltip。 +8. 胜率图点击/拖拽状态。 +9. BoardInsightPanel。 +10. 整盘复盘:关键手列表 + 胜率图。 +11. 最近 10 局训练计划。 + +## Evidence + +Use `docs/VISUAL_QA_EVIDENCE_TEMPLATE.md` to record the final result. diff --git a/docs/VISUAL_QA_EVIDENCE_TEMPLATE.md b/docs/VISUAL_QA_EVIDENCE_TEMPLATE.md new file mode 100644 index 0000000..effa068 --- /dev/null +++ b/docs/VISUAL_QA_EVIDENCE_TEMPLATE.md @@ -0,0 +1,48 @@ +# Visual QA Evidence Template + +Use this template for `v0.2.0-beta.1` visual acceptance. Keep screenshots in `release-evidence/` or attach them to the PR/release discussion. Do not commit local screenshots by default. + +## Build + +- Version: +- Commit: +- Platform: +- Artifact: +- Tester: +- Date: + +## Required Screenshots + +- [ ] DiagnosticsGate. +- [ ] Settings readiness panel. +- [ ] Main workbench three-column layout. +- [ ] GoBoardV2 with coordinates, stones, last move, and candidates. +- [ ] CandidateTooltip with visits, winrate, and score. +- [ ] WinrateTimelineV2 with move marker and interaction state. +- [ ] BoardInsightPanel. +- [ ] TeacherRunCardPro structured result. +- [ ] TeacherKeyMoveActions / key-move jump buttons. +- [ ] SGF student bind dialog. +- [ ] Student rail card. + +## Acceptance Notes + +For each screenshot, record: + +- Viewport size. +- Whether text clips or overlaps. +- Whether board/timeline/teacher panels preserve spacing. +- Whether candidate labels remain readable. +- Whether dark theme contrast is sufficient. + +## Result + +- [ ] Pass. +- [ ] Pass with notes. +- [ ] Fail. + +Blocking issues: + +1. +2. +3. diff --git a/docs/WINDOWS_CODE_SIGNING.md b/docs/WINDOWS_CODE_SIGNING.md new file mode 100644 index 0000000..1fa440f --- /dev/null +++ b/docs/WINDOWS_CODE_SIGNING.md @@ -0,0 +1,47 @@ +# Windows Code Signing + +GoMentor public beta installers should be code signed before distribution. Unsigned Windows builds are internal beta artifacts only and may trigger SmartScreen warnings. + +## Option 1: EV/OV Certificate + +Use a certificate from a trusted CA. Configure CI secrets instead of local paths: + +- `WIN_CSC_LINK`: base64 `.pfx` content or secure URL. +- `WIN_CSC_KEY_PASSWORD`: certificate password. + +electron-builder also recognizes `CSC_LINK` and `CSC_KEY_PASSWORD`, but prefer the Windows-specific variables when separate certificates are used. + +## Option 2: Microsoft Trusted Signing + +Microsoft Trusted Signing can be used through the electron-builder Azure signing path. Store account, endpoint, profile, and tenant values as CI secrets. Do not place tenant IDs, client secrets, or local certificate paths in the repository. + +## Build + +```bash +pnpm install +node scripts/prepare_katago_assets.mjs +node scripts/check_katago_assets.mjs --mode=release +pnpm dist:win +``` + +P0 beta publishes Windows x64 only: + +- `GoMentor-0.2.0-beta.1-win-x64.exe` +- optional portable ZIP: `GoMentor-0.2.0-beta.1-win-x64-portable.zip` + +Do not publish Windows ARM64 until `win32-arm64` KataGo assets and checks exist. + +## Verification + +On Windows: + +```powershell +Get-AuthenticodeSignature .\release\0.2.0-beta.1\GoMentor-0.2.0-beta.1-win-x64.exe +signtool verify /pa /v .\release\0.2.0-beta.1\GoMentor-0.2.0-beta.1-win-x64.exe +``` + +The installer should show a valid publisher. If it is unsigned, mark the build as internal beta only. + +## Current Blocker + +Before tagging `v0.2.0-beta.1`, record Windows signing status in release evidence and complete `docs/WINDOWS_SMOKE_TEST.md`. diff --git a/docs/WINDOWS_SMOKE_TEST.md b/docs/WINDOWS_SMOKE_TEST.md new file mode 100644 index 0000000..2a1a12c --- /dev/null +++ b/docs/WINDOWS_SMOKE_TEST.md @@ -0,0 +1,54 @@ +# Windows 11 x64 Smoke Test + +This checklist is manual required before tagging `v0.2.0-beta.1`. A GitHub Windows runner can verify build scripts, but it does not replace real installation and UI smoke on a Windows desktop. + +## Test Matrix + +- OS: Windows 11 x64. +- User path: include one account with a Chinese username or a path containing Chinese characters. +- Artifact: `GoMentor-0.2.0-beta.1-win-x64.exe`. +- Optional: `GoMentor-0.2.0-beta.1-win-x64-portable.zip`. + +## Installer + +- [ ] Installer launches. +- [ ] Installer completes without admin-only assumptions. +- [ ] App launches from Start Menu or installed location. +- [ ] App can be uninstalled cleanly. +- [ ] Installer signature is valid, or build is explicitly marked unsigned/internal beta. + +## Filesystem and Runtime + +- [ ] User data directory is writable under the Chinese username path. +- [ ] `resources/data/katago/bin/win32-x64/katago.exe` exists. +- [ ] KataGo reports `v1.16.4`. +- [ ] Default model is readable. +- [ ] First-launch diagnostics are not blocked by missing KataGo. +- [ ] If LLM credentials are absent, diagnostics show warning, not a crash. + +## Core Product Flow + +- [ ] SGF import works. +- [ ] Imported game opens on the board. +- [ ] Student binding dialog can bind or create a student. +- [ ] Fox nickname sync runs; if the remote source fails, the error is readable. +- [ ] Current-move analysis runs. +- [ ] Full-game analysis runs. +- [ ] Recent 10-game analysis runs for a student with enough games. +- [ ] Teacher structured result cards render correctly. +- [ ] Tool logs are collapsible. +- [ ] Candidate tooltip displays recommendation, visits, winrate, and score. +- [ ] Winrate timeline click and drag moves to the expected move number. + +## Evidence + +Record: + +- Windows version and machine type. +- Artifact filename and SHA256. +- Signing status. +- KataGo `version` output. +- Screenshots of diagnostics, workbench, candidate tooltip, timeline, and teacher card. +- Any failure logs or user-facing error text. + +Store evidence locally under `release-evidence/` or attach it to the PR/release checklist. Do not commit raw screenshots unless the release owner explicitly asks. diff --git a/package.json b/package.json index 387de53..a90a9bf 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,18 @@ { - "name": "katasensei", - "version": "0.1.0", + "name": "gomentor", + "version": "0.2.0-beta.1", "description": "KataGo + multimodal LLM desktop workbench for Go students and teachers.", "main": "out/main/index.js", "type": "module", "author": "haoc", "license": "MIT", - "homepage": "https://github.com/wimi321/katasensei#readme", + "homepage": "https://github.com/wimi321/GoMentor#readme", "repository": { "type": "git", - "url": "git+https://github.com/wimi321/katasensei.git" + "url": "git+https://github.com/wimi321/GoMentor.git" }, "bugs": { - "url": "https://github.com/wimi321/katasensei/issues" + "url": "https://github.com/wimi321/GoMentor/issues" }, "packageManager": "pnpm@10.30.3", "scripts": { @@ -29,7 +29,17 @@ "dist:win": "pnpm build && electron-builder --win --publish never", "dist:linux": "pnpm build && electron-builder --linux --publish never", "postinstall": "electron-builder install-app-deps", - "prepare:python": "python3 -m pip install -r scripts/requirements.txt" + "prepare:python": "python3 -m pip install -r scripts/requirements.txt", + "prepare:katago-assets": "node scripts/prepare_katago_assets.mjs", + "check:katago-assets": "node scripts/check_katago_assets.mjs --mode=dev", + "check:katago-assets:release": "node scripts/check_katago_assets.mjs --mode=release", + "check:p0-beta": "node scripts/p0_beta_acceptance.mjs", + "check:artifacts": "node scripts/package_artifact_smoke.mjs --mode=dev", + "smoke:teacher-llm": "pnpm build && node scripts/teacher_llm_smoke.mjs", + "smoke:teacher-llm:real": "pnpm build && node scripts/teacher_llm_real_smoke.mjs", + "rc:check": "node scripts/p0_release_candidate_check.mjs --mode=dev", + "rc:artifacts": "node scripts/verify_release_artifacts.mjs --mode=dev", + "rc:evidence": "node scripts/collect_release_evidence.mjs --mode=dev" }, "dependencies": { "electron-store": "^10.0.1", @@ -53,8 +63,8 @@ "vite": "^7.1.5" }, "build": { - "appId": "com.katasensei.desktop", - "productName": "KataSensei", + "appId": "com.gomentor.desktop", + "productName": "GoMentor", "icon": "assets/icon", "directories": { "output": "release/${version}" @@ -83,6 +93,11 @@ "mac": { "category": "public.app-category.education", "icon": "assets/icon.icns", + "hardenedRuntime": true, + "gatekeeperAssess": false, + "entitlements": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.inherit.plist", + "notarize": true, "target": [ { "target": "dmg", @@ -102,22 +117,25 @@ }, "win": { "icon": "assets/icon.ico", + "artifactName": "${productName}-${version}-${os}-${arch}-portable.${ext}", "target": [ { "target": "nsis", "arch": [ - "x64", - "arm64" + "x64" ] }, { - "target": "portable", + "target": "zip", "arch": [ "x64" ] } ] }, + "nsis": { + "artifactName": "${productName}-${version}-${os}-${arch}.${ext}" + }, "linux": { "icon": "assets/icon.png", "target": [ @@ -130,7 +148,7 @@ "publish": { "provider": "github", "owner": "wimi321", - "repo": "katasensei" + "repo": "GoMentor" } } } diff --git a/scripts/bootstrap.ps1 b/scripts/bootstrap.ps1 index 95c4f37..e89686c 100644 --- a/scripts/bootstrap.ps1 +++ b/scripts/bootstrap.ps1 @@ -11,5 +11,5 @@ python -m pip install -r scripts/requirements.txt Write-Host "[3/4] Preparing KataGo" python scripts/install_katago_latest.py -Write-Host "[4/4] Launching KataSensei" +Write-Host "[4/4] Launching GoMentor" pnpm dev diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 25b6d66..d811973 100644 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -13,5 +13,5 @@ python3 -m pip install -r scripts/requirements.txt echo "[3/4] Preparing KataGo" python3 scripts/install_katago_latest.py || true -echo "[4/4] Launching KataSensei" +echo "[4/4] Launching GoMentor" pnpm dev diff --git a/scripts/capture_ui_gallery.mjs b/scripts/capture_ui_gallery.mjs new file mode 100644 index 0000000..1de7e10 --- /dev/null +++ b/scripts/capture_ui_gallery.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import { mkdir } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' + +const url = process.env.GOMENTOR_UI_GALLERY_URL ?? 'http://localhost:5173/#/ui-gallery' +const outDir = resolve(process.env.GOMENTOR_UI_GALLERY_OUT ?? 'release-evidence/ui-gallery') + +async function loadPlaywright() { + try { + return await import('playwright') + } catch { + return null + } +} + +async function captureWithCliFallback() { + await mkdir(outDir, { recursive: true }) + const overviewPath = join(outDir, 'ui-gallery-overview.png') + const result = spawnSync('npx', [ + '--yes', + 'playwright', + 'screenshot', + '--viewport-size=1440,1100', + '--full-page', + '--wait-for-selector=.ui-gallery', + url, + overviewPath + ], { + stdio: 'inherit' + }) + if (result.status !== 0) { + throw new Error('Playwright package is not installed and npx playwright screenshot failed. Run pnpm dev, then capture this route manually: ' + url) + } + console.log(`Captured UI Gallery overview in ${overviewPath}`) +} + +async function capture() { + const playwright = await loadPlaywright() + if (!playwright) { + await captureWithCliFallback() + return + } + const { chromium } = playwright + await mkdir(outDir, { recursive: true }) + const browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1440, height: 1100 }, deviceScaleFactor: 1 }) + await page.goto(url, { waitUntil: 'networkidle' }) + await page.screenshot({ path: join(outDir, 'ui-gallery-overview.png'), fullPage: true }) + + const targets = [ + ['board', '.ui-gallery__panel--board'], + ['teacher-card', '.ui-gallery__panel--teacher'], + ['timeline', '.ks-timeline-v2'], + ['diagnostics', '.diagnostics-page'], + ['settings-readiness', '.beta-acceptance-panel'] + ] + + 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() + console.log(`Captured UI Gallery screenshots in ${outDir}`) +} + +capture().catch((error) => { + console.error(error.message) + process.exit(1) +}) diff --git a/scripts/check_katago_assets.mjs b/scripts/check_katago_assets.mjs new file mode 100755 index 0000000..3a61683 --- /dev/null +++ b/scripts/check_katago_assets.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +import { access, readFile, stat } from 'node:fs/promises' +import { constants } from 'node:fs' +import { createHash } from 'node:crypto' +import { join, resolve } from 'node:path' +import process from 'node:process' + +const root = resolve(process.cwd()) +const manifestPath = join(root, 'data', 'katago', 'manifest.json') + +function arg(name, fallback = '') { + const prefix = `--${name}=` + const found = process.argv.find((item) => item.startsWith(prefix)) + return found ? found.slice(prefix.length) : fallback +} + +function hasFlag(name) { + return process.argv.includes(`--${name}`) +} + +function currentPlatformKey() { + return arg('platform', `${process.platform}-${process.arch}`) +} + +async function fileOk(path, executable = false) { + try { + await stat(path) + if (executable && process.platform !== 'win32') { + await access(path, constants.X_OK) + } + return true + } catch { + return false + } +} + +async function sha256(path) { + const data = await readFile(path) + return createHash('sha256').update(data).digest('hex') +} + +async function main() { + const mode = arg('mode', hasFlag('release') ? 'release' : 'dev') + const key = currentPlatformKey() + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + const platform = manifest.supportedPlatforms?.[key] + if (!platform) { + const message = `Unsupported platform ${key}. Supported: ${Object.keys(manifest.supportedPlatforms ?? {}).join(', ')}` + if (mode === 'release') throw new Error(message) + console.warn(`[check-katago-assets] warning: ${message}`) + return + } + + const binaryPath = join(root, 'data', 'katago', platform.binaryPath) + const modelPath = join(root, 'data', 'katago', manifest.modelPath) + const binaryOk = await fileOk(binaryPath, true) + const modelOk = await fileOk(modelPath, false) + + if (binaryOk) { + console.log(`[check-katago-assets] binary OK: ${platform.binaryPath}`) + if (platform.sha256) { + const actual = await sha256(binaryPath) + if (actual !== platform.sha256) throw new Error(`Binary checksum mismatch: expected ${platform.sha256}, got ${actual}`) + } + } else { + console.warn(`[check-katago-assets] missing binary: ${platform.binaryPath}`) + } + + if (modelOk) { + console.log(`[check-katago-assets] model OK: ${manifest.modelPath}`) + if (manifest.modelSha256) { + const actual = await sha256(modelPath) + if (actual !== manifest.modelSha256) throw new Error(`Model checksum mismatch: expected ${manifest.modelSha256}, got ${actual}`) + } + } else { + console.warn(`[check-katago-assets] missing model: ${manifest.modelPath}`) + } + + if (mode === 'release' && (!binaryOk || !modelOk)) { + throw new Error('Release packaging requires both KataGo binary and default model. Run scripts/prepare_katago_assets.mjs first.') + } + + if (!binaryOk || !modelOk) { + console.warn('[check-katago-assets] development warning only. Diagnostics should show missing assets to the user.') + } +} + +main().catch((error) => { + console.error(`[check-katago-assets] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/scripts/collect_release_evidence.mjs b/scripts/collect_release_evidence.mjs new file mode 100755 index 0000000..4b9f34c --- /dev/null +++ b/scripts/collect_release_evidence.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' + +const root = process.cwd() +const args = new Set(process.argv.slice(2)) +const modeArg = process.argv.find((arg) => arg.startsWith('--mode=')) +const mode = modeArg ? modeArg.split('=')[1] : 'dev' +const stamp = new Date().toISOString().replace(/[:.]/g, '-') +const outDir = join(root, 'release-evidence', stamp) +mkdirSync(outDir, { recursive: true }) + +function run(name, command, args) { + const result = spawnSync(command, args, { cwd: root, encoding: 'utf8' }) + const payload = { + name, + command: [command, ...args], + status: result.status, + stdout: result.stdout, + stderr: result.stderr + } + writeFileSync(join(outDir, `${name}.json`), JSON.stringify(payload, null, 2)) + return payload +} + +function readMaybe(path) { + try { return readFileSync(join(root, path), 'utf8') } catch { return '' } +} + +const evidence = { + mode, + collectedAt: new Date().toISOString(), + node: process.version, + platform: process.platform, + arch: process.arch, + gitHead: run('git-head', 'git', ['rev-parse', 'HEAD']).stdout.trim(), + gitBranch: run('git-branch', 'git', ['branch', '--show-current']).stdout.trim(), + packageJson: readMaybe('package.json'), + katagoManifest: readMaybe('data/katago/manifest.json') +} + +const checks = [ + run('git-status', 'git', ['status', '--short']), + run('katago-assets', 'node', ['scripts/check_katago_assets.mjs', `--mode=${mode}`]), + run('p0-beta-acceptance', 'node', ['scripts/p0_beta_acceptance.mjs']), + run('package-artifact-smoke', 'node', ['scripts/package_artifact_smoke.mjs', `--mode=${mode}`]), + run('p0-release-candidate', 'node', ['scripts/p0_release_candidate_check.mjs', `--mode=${mode}`]), + run('verify-release-artifacts', 'node', ['scripts/verify_release_artifacts.mjs', `--mode=${mode}`]) +] + +evidence.checks = checks.map((item) => ({ name: item.name, status: item.status })) +writeFileSync(join(outDir, 'release-evidence-summary.json'), JSON.stringify(evidence, null, 2)) +writeFileSync(join(outDir, 'README.md'), [ + '# GoMentor Release Evidence', + '', + `Mode: ${mode}`, + `Collected at: ${evidence.collectedAt}`, + `Git branch: ${evidence.gitBranch}`, + `Git head: ${evidence.gitHead}`, + '', + 'Open `release-evidence-summary.json` and each check JSON for details.' +].join('\n')) + +console.log(`Release evidence written to ${outDir.replace(root + '/', '')}`) +const failures = checks.filter((item) => item.status !== 0) +process.exit(failures.length > 0 && mode === 'release' ? 1 : 0) diff --git a/scripts/p0_beta_acceptance.mjs b/scripts/p0_beta_acceptance.mjs new file mode 100755 index 0000000..4b2cbd6 --- /dev/null +++ b/scripts/p0_beta_acceptance.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' + +const root = process.cwd() +const failures = [] +const warnings = [] +const passes = [] + +function checkPath(relativePath, { required = true, note = '' } = {}) { + const ok = existsSync(join(root, relativePath)) + const line = `${relativePath}${note ? ` — ${note}` : ''}` + if (ok) passes.push(line) + else if (required) failures.push(line) + else warnings.push(line) +} + +function checkText(relativePath, pattern, label, { required = true } = {}) { + const file = join(root, relativePath) + if (!existsSync(file)) { + failures.push(`${label}: missing ${relativePath}`) + return + } + const text = readFileSync(file, 'utf8') + const ok = typeof pattern === 'string' ? text.includes(pattern) : pattern.test(text) + if (ok) passes.push(label) + else if (required) failures.push(label) + else warnings.push(label) +} + +checkPath('package.json') +checkPath('src/main/services/diagnostics/index.ts') +checkPath('src/main/services/llm/openaiCompatibleProvider.ts') +checkPath('src/main/services/studentProfile.ts') +checkPath('src/main/services/teacherAgent.ts') +checkPath('data/knowledge/p0-cards.json') +checkPath('data/katago/manifest.json') +checkPath('scripts/check_katago_assets.mjs') +checkPath('src/renderer/src/features/board/GoBoardV2.tsx') +checkPath('src/renderer/src/features/board/WinrateTimelineV2.tsx') +checkPath('src/renderer/src/features/teacher/TeacherRunCardPro.tsx') +checkPath('src/renderer/src/features/diagnostics/DiagnosticsGate.tsx', { required: false, note: '如果诊断 gate 文件名不同,可忽略此 warning' }) + +checkText('package.json', /"typecheck"\s*:/, 'package.json has typecheck script') +checkText('package.json', /"build"\s*:/, 'package.json has build script') +checkText('package.json', /"test"\s*:/, 'package.json has test script') +checkText('.github/workflows/release.yml', /prepare.*katago|KataGo assets|check_katago_assets|prepare_katago_assets/i, 'release workflow prepares KataGo assets', { required: false }) +checkText('src/main/index.ts', /diagnostics|katago-assets|student/i, 'main process exposes P0 IPC contracts') + +try { + const cards = JSON.parse(readFileSync(join(root, 'data/knowledge/p0-cards.json'), 'utf8')) + const count = Array.isArray(cards) ? cards.length : Array.isArray(cards.cards) ? cards.cards.length : 0 + if (count >= 40) passes.push(`knowledge cards count >= 40 (${count})`) + else failures.push(`knowledge cards count too low (${count})`) +} catch (error) { + failures.push(`knowledge cards parse failed: ${error.message}`) +} + +console.log('\nGoMentor P0 Beta Acceptance') +console.log('================================') +for (const line of passes) console.log(`✅ ${line}`) +for (const line of warnings) console.log(`⚠️ ${line}`) +for (const line of failures) console.log(`❌ ${line}`) +console.log('================================') +console.log(`passes=${passes.length} warnings=${warnings.length} failures=${failures.length}`) + +if (failures.length > 0) { + process.exit(1) +} diff --git a/scripts/p0_release_candidate_check.mjs b/scripts/p0_release_candidate_check.mjs new file mode 100755 index 0000000..c8ed666 --- /dev/null +++ b/scripts/p0_release_candidate_check.mjs @@ -0,0 +1,312 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, statSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' + +const root = process.cwd() +const args = new Set(process.argv.slice(2)) +const modeArg = process.argv.find((arg) => arg.startsWith('--mode=')) +const mode = modeArg ? modeArg.split('=')[1] : (args.has('--release') ? 'release' : 'dev') +const json = args.has('--json') +const p0BetaVersion = '0.2.0-beta.1' + +const results = [] + +function rel(path) { + return path.replace(root + '/', '') +} + +function push(status, id, title, detail = '') { + results.push({ status, id, title, detail }) +} + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + return null + } +} + +function readText(path) { + try { + return readFileSync(path, 'utf8') + } catch { + return '' + } +} + +function file(path, options = {}) { + const full = join(root, path) + if (!existsSync(full)) { + push(options.required === false ? 'warning' : 'fail', `file:${path}`, `${path} exists`, 'Missing file') + return false + } + if (options.minBytes) { + const size = statSync(full).size + if (size < options.minBytes) { + push('warning', `file-size:${path}`, `${path} has expected size`, `Found ${size} bytes, expected >= ${options.minBytes}`) + return true + } + } + push('pass', `file:${path}`, `${path} exists`) + return true +} + +function contains(path, needles, title) { + const text = readText(join(root, path)) + if (!text) { + push('fail', `contains:${path}`, title ?? `${path} contains expected text`, 'Missing or unreadable file') + return false + } + const missing = needles.filter((needle) => !text.includes(needle)) + if (missing.length > 0) { + push('fail', `contains:${path}`, title ?? `${path} contains expected text`, `Missing: ${missing.join(', ')}`) + return false + } + push('pass', `contains:${path}`, title ?? `${path} contains expected text`) + return true +} + +function checkPackageJson() { + const pkg = readJson(join(root, 'package.json')) + if (!pkg) { + push('fail', 'package-json', 'package.json is readable') + return + } + push('pass', 'package-json', 'package.json is readable') + push( + pkg.version === p0BetaVersion ? 'pass' : 'fail', + 'package-version', + `package version is ${p0BetaVersion}`, + `Found ${pkg.version ?? 'missing'}` + ) + const scripts = pkg.scripts ?? {} + for (const script of ['typecheck', 'build', 'check', 'dist:mac', 'dist:win']) { + push(scripts[script] ? 'pass' : 'fail', `script:${script}`, `package script ${script} exists`) + } + if (scripts['rc:check'] || existsSync(join(root, 'scripts/p0_release_candidate_check.mjs'))) { + push('pass', 'script:rc-check', 'RC readiness script exists') + } else { + push('warning', 'script:rc-check', 'RC readiness script exists', 'Add rc:check alias in package.json') + } + + const build = pkg.build ?? {} + const extra = JSON.stringify(build.extraResources ?? []) + const unpack = JSON.stringify(build.asarUnpack ?? []) + if (extra.includes('data/katago')) { + push('pass', 'builder:extraResources', 'electron-builder includes data/katago extraResources') + } else { + push('fail', 'builder:extraResources', 'electron-builder includes data/katago extraResources') + } + if (unpack.includes('data/katago')) { + push('pass', 'builder:asarUnpack', 'electron-builder unpacks data/katago') + } else { + push('warning', 'builder:asarUnpack', 'electron-builder unpacks data/katago', 'KataGo should not run from inside asar') + } + + const winTargets = build.win?.target ?? [] + const winTargetText = JSON.stringify(winTargets) + const hasWinX64 = winTargetText.includes('x64') + const hasWinArm64 = winTargetText.includes('arm64') + push(hasWinX64 ? 'pass' : 'fail', 'builder:win-x64', 'Windows x64 target is configured') + push(!hasWinArm64 ? 'pass' : 'fail', 'builder:no-win-arm64', 'Windows ARM64 target is disabled for P0 beta') + + const artifactName = String(build.artifactName ?? '') + push( + artifactName.includes('${version}') && artifactName.includes('${os}') && artifactName.includes('${arch}') + ? 'pass' + : 'warning', + 'builder:artifact-name', + 'artifactName includes version, os, and arch', + artifactName + ) +} + +function checkCoreFiles() { + const required = [ + 'src/main/services/diagnostics/index.ts', + 'src/main/services/llm/openaiCompatibleProvider.ts', + 'src/main/services/studentProfile.ts', + 'src/main/services/teacherAgent.ts', + 'data/knowledge/p0-cards.json', + 'data/katago/manifest.json', + 'scripts/check_katago_assets.mjs', + 'scripts/p0_beta_acceptance.mjs', + 'scripts/package_artifact_smoke.mjs' + ] + for (const path of required) { + file(path) + } +} + +function checkKnowledgeCards() { + const cards = readJson(join(root, 'data/knowledge/p0-cards.json')) + if (!Array.isArray(cards)) { + push('fail', 'knowledge:cards-array', 'P0 knowledge cards are an array') + return + } + const count = cards.length + push(count >= 48 ? 'pass' : 'fail', 'knowledge:cards-count', 'P0 knowledge cards count >= 48', `Found ${count}`) + const missingCore = cards.filter((card) => !card.id || !card.title || !card.summary).length + push(missingCore === 0 ? 'pass' : 'warning', 'knowledge:card-core-fields', 'Knowledge cards have core fields', `${missingCore} cards missing id/title/summary`) +} + +function checkWorkflow() { + const release = '.github/workflows/release.yml' + const p0 = '.github/workflows/p0-release-candidate.yml' + if (existsSync(join(root, release))) { + const text = readText(join(root, release)) + const hasPrepare = text.includes('prepare_katago_assets') || text.includes('check_katago_assets') || text.includes('KataGo') + push(hasPrepare ? 'pass' : 'warning', 'workflow:release-katago', 'Release workflow mentions KataGo asset preparation') + } else { + push('warning', 'workflow:release', 'Release workflow exists') + } + file(p0, { required: false }) +} + +function checkDocs() { + const docs = [ + 'docs/RELEASE_BETA_CHECKLIST.md', + 'docs/VISUAL_QA_CHECKLIST.md', + 'docs/KATAGO_ASSETS.md', + 'docs/P0_STATUS.md', + 'docs/MACOS_SIGNING_NOTARIZATION.md', + 'docs/WINDOWS_CODE_SIGNING.md', + 'docs/WINDOWS_SMOKE_TEST.md', + 'docs/VISUAL_QA_EVIDENCE_TEMPLATE.md', + 'docs/RELEASE_NOTES_v0.2.0-beta.1.md' + ] + for (const path of docs) file(path, { required: false }) + file('docs/RC_RELEASE_GUIDE.md', { required: false }) + file('docs/RELEASE_SMOKE_MATRIX.md', { required: false }) +} + +function checkManualReleaseBlockers() { + const signingEvidence = + process.env.GOMENTOR_SIGNING_READY === '1' || + existsSync(join(root, 'release-evidence', 'signing-ready.json')) + const windowsSmokeEvidence = + process.env.GOMENTOR_WINDOWS_SMOKE_READY === '1' || + existsSync(join(root, 'release-evidence', 'windows-smoke-ready.json')) + const visualQaEvidence = + process.env.GOMENTOR_VISUAL_QA_READY === '1' || + existsSync(join(root, 'release-evidence', 'visual-qa-ready.json')) + + push( + signingEvidence ? 'pass' : 'warning', + 'manual:signing-ready', + 'macOS and Windows signing evidence is present', + signingEvidence ? '' : 'Manual blocker before public beta: signed/notarized macOS app and signed Windows installer not verified' + ) + push( + windowsSmokeEvidence ? 'pass' : 'warning', + 'manual:windows-smoke-ready', + 'Windows real-machine smoke evidence is present', + windowsSmokeEvidence ? '' : 'Manual blocker before tag: Windows 11 x64 smoke test required' + ) + push( + visualQaEvidence ? 'pass' : 'warning', + 'manual:visual-qa-ready', + 'Visual QA evidence is present', + visualQaEvidence ? '' : 'Manual blocker before public beta: visual screenshots/checklist required' + ) + push( + signingEvidence && windowsSmokeEvidence && visualQaEvidence ? 'pass' : 'warning', + 'manual:public-beta-ready', + 'Public beta manual gates are complete', + signingEvidence && windowsSmokeEvidence && visualQaEvidence + ? '' + : 'publicBetaReady=false until signing, Windows smoke, and visual QA evidence are all present' + ) +} + +function checkKatagoAssets() { + const manifest = readJson(join(root, 'data/katago/manifest.json')) + if (!manifest) { + push('fail', 'katago:manifest', 'KataGo manifest is readable') + return + } + push('pass', 'katago:manifest', 'KataGo manifest is readable') + + const paths = [] + const manifestPath = (path) => { + if (!path) return null + return path.startsWith('data/katago/') ? path : `data/katago/${path}` + } + if (manifest.modelFileName) paths.push(`data/katago/models/${manifest.modelFileName}`) + if (manifest.defaultModelFileName) paths.push(`data/katago/models/${manifest.defaultModelFileName}`) + if (manifest.modelPath) paths.push(manifestPath(manifest.modelPath)) + if (manifest.defaultModel?.path) paths.push(manifestPath(manifest.defaultModel.path)) + const platformPaths = manifest.supportedPlatforms ?? manifest.platforms ?? {} + if (Array.isArray(platformPaths)) { + for (const item of platformPaths) { + if (item.binaryPath) paths.push(manifestPath(item.binaryPath)) + } + } else { + for (const value of Object.values(platformPaths)) { + if (typeof value === 'string') paths.push(manifestPath(value)) + if (value?.binaryPath) paths.push(manifestPath(value.binaryPath)) + } + } + + const missing = [...new Set(paths)].filter((path) => !existsSync(join(root, path))) + if (missing.length === 0 && paths.length > 0) { + push('pass', 'katago:assets-present', 'KataGo manifest assets exist') + } else if (mode === 'release') { + push('fail', 'katago:assets-present', 'KataGo manifest assets exist', `Missing: ${missing.join(', ') || 'no paths declared'}`) + } else { + push('warning', 'katago:assets-present', 'KataGo assets missing in dev mode', `Missing: ${missing.join(', ') || 'no paths declared'}`) + } +} + +function checkNoObviousSecrets() { + const candidates = ['.env', '.env.local', 'release', 'out', 'node_modules'] + for (const path of candidates) { + if (existsSync(join(root, path))) { + push('warning', `workspace:${path}`, `${path} not meant for commit`, 'Exists locally; verify not tracked') + } else { + push('pass', `workspace:${path}`, `${path} absent from workspace root`) + } + } + + const git = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }) + if (git.status === 0) { + const risky = git.stdout.split('\n').filter((line) => { + const path = line.slice(3).trim().split(' -> ').pop() ?? '' + return ( + /(^|\/)\.env/.test(path) || + /^(node_modules|out|release)\//.test(path) || + /\.(zip|dmg|exe)$/i.test(path) + ) + }) + push(risky.length === 0 ? 'pass' : 'fail', 'git:no-risky-files', 'No obvious risky files staged/modified', risky.join('\n')) + } else { + push('warning', 'git:status', 'git status available') + } +} + +checkPackageJson() +checkCoreFiles() +checkKnowledgeCards() +checkWorkflow() +checkDocs() +checkKatagoAssets() +checkManualReleaseBlockers() +checkNoObviousSecrets() + +const failures = results.filter((item) => item.status === 'fail') +const warnings = results.filter((item) => item.status === 'warning') + +if (json) { + console.log(JSON.stringify({ mode, pass: results.length - failures.length - warnings.length, warnings: warnings.length, failures: failures.length, results }, null, 2)) +} else { + console.log(`P0 release candidate check (${mode})`) + for (const item of results) { + const icon = item.status === 'pass' ? '✓' : item.status === 'warning' ? '!' : '✗' + console.log(`${icon} ${item.title}${item.detail ? ` — ${item.detail}` : ''}`) + } + console.log(`\nSummary: ${results.length - failures.length - warnings.length} pass / ${warnings.length} warnings / ${failures.length} failures`) +} + +process.exit(failures.length > 0 ? 1 : 0) diff --git a/scripts/package_artifact_smoke.mjs b/scripts/package_artifact_smoke.mjs new file mode 100755 index 0000000..06c045d --- /dev/null +++ b/scripts/package_artifact_smoke.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +const args = new Set(process.argv.slice(2)) +const mode = args.has('--mode=release') ? 'release' : 'dev' +const root = process.cwd() +const releaseRoot = join(root, 'release') +const packageVersion = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version +const versionReleaseRoot = join(releaseRoot, packageVersion) +const scanRoot = existsSync(versionReleaseRoot) ? versionReleaseRoot : releaseRoot + +function collectFiles(directory) { + if (!existsSync(directory)) return [] + const out = [] + const stack = [directory] + while (stack.length) { + const current = stack.pop() + for (const name of readdirSync(current, { withFileTypes: true })) { + const full = join(current, name.name) + if (name.isDirectory()) stack.push(full) + else out.push(full) + } + } + return out +} + +const files = collectFiles(scanRoot) +const artifactPatterns = [/\.dmg$/i, /\.zip$/i, /\.exe$/i, /\.AppImage$/i, /\.deb$/i, /\.tar\.gz$/i] +const artifacts = files.filter((file) => artifactPatterns.some((pattern) => pattern.test(file))) +const hasMacArm64Dmg = artifacts.some((file) => /mac-arm64\.dmg$/i.test(file)) +const hasMacX64Dmg = artifacts.some((file) => /mac-x64\.dmg$/i.test(file)) +const hasWinX64Installer = artifacts.some((file) => /win-x64\.exe$/i.test(file) && !/portable/i.test(file)) +const hasWinX64PortableZip = artifacts.some((file) => /win-x64-portable\.zip$/i.test(file)) +const winPortableExe = artifacts.filter((file) => /win-x64-portable\.exe$/i.test(file)) +const winArm64 = artifacts.filter((file) => /win-arm64/i.test(file)) + +console.log('\nGoMentor Package Artifact Smoke Check') +console.log('======================================') +console.log(`mode=${mode}`) +console.log(`releaseRoot=${releaseRoot}`) +console.log(`packageVersion=${packageVersion}`) +console.log(`scanRoot=${scanRoot}`) +console.log(`artifactCount=${artifacts.length}`) +for (const artifact of artifacts.slice(0, 20)) console.log(`- ${artifact}`) + +if (mode === 'release') { + const failures = [] + if (!existsSync(versionReleaseRoot)) failures.push(`release directory missing for package version ${packageVersion}`) + if (!hasMacArm64Dmg) failures.push('macOS arm64 DMG missing') + if (!hasMacX64Dmg) failures.push('macOS x64 DMG missing') + if (!hasWinX64Installer) failures.push('Windows x64 installer missing') + if (!hasWinX64PortableZip) failures.push('Windows x64 portable ZIP missing') + if (winPortableExe.length > 0) failures.push(`Windows portable artifact must be a ZIP, not an EXE: ${winPortableExe.join(', ')}`) + if (winArm64.length > 0) failures.push(`Windows ARM64 artifacts are not supported for P0 beta: ${winArm64.join(', ')}`) + if (failures.length) { + for (const failure of failures) console.error(`❌ ${failure}`) + process.exit(1) + } + console.log('✅ release artifacts look present') +} else if (!existsSync(releaseRoot) || artifacts.length === 0) { + console.log('⚠️ no release artifacts found; this is acceptable in dev mode before pnpm dist') +} else { + console.log('✅ artifacts found') +} diff --git a/scripts/prepare_katago_assets.mjs b/scripts/prepare_katago_assets.mjs new file mode 100755 index 0000000..85a86d0 --- /dev/null +++ b/scripts/prepare_katago_assets.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +import { chmod, copyFile, mkdir, readFile, stat } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { dirname, join, resolve } from 'node:path' +import process from 'node:process' + +const root = resolve(process.cwd()) +const manifestPath = join(root, 'data', 'katago', 'manifest.json') + +function arg(name, fallback = '') { + const prefix = `--${name}=` + const found = process.argv.find((item) => item.startsWith(prefix)) + return found ? found.slice(prefix.length) : fallback +} + +function platformKey() { + return arg('platform', `${process.platform}-${process.arch}`) +} + +async function exists(path) { + try { + await stat(path) + return true + } catch { + return false + } +} + +async function sha256(path) { + const data = await readFile(path) + return createHash('sha256').update(data).digest('hex') +} + +async function copyIfProvided(source, target, label) { + if (!source) { + console.log(`[prepare-katago-assets] ${label}: no source provided, skip`) + return false + } + const sourcePath = resolve(source) + if (!(await exists(sourcePath))) { + throw new Error(`${label} source does not exist: ${sourcePath}`) + } + await mkdir(dirname(target), { recursive: true }) + await copyFile(sourcePath, target) + if (process.platform !== 'win32' && !target.endsWith('.bin.gz')) { + await chmod(target, 0o755).catch(() => undefined) + } + console.log(`[prepare-katago-assets] copied ${label}: ${sourcePath} -> ${target}`) + console.log(`[prepare-katago-assets] ${label} sha256=${await sha256(target)}`) + return true +} + +async function main() { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) + const key = platformKey() + const platform = manifest.supportedPlatforms?.[key] + if (!platform) { + throw new Error(`Unsupported platform key: ${key}. Supported: ${Object.keys(manifest.supportedPlatforms ?? {}).join(', ')}`) + } + + const binarySource = arg('binary', process.env.GOMENTOR_KATAGO_BINARY ?? '') + const modelSource = arg('model', process.env.GOMENTOR_KATAGO_MODEL ?? '') + const assetDir = arg('asset-dir', process.env.GOMENTOR_KATAGO_ASSET_DIR ?? '') + + const binaryFallback = assetDir ? join(resolve(assetDir), platform.binaryPath) : '' + const modelFallback = assetDir ? join(resolve(assetDir), manifest.modelPath) : '' + + const binaryTarget = join(root, 'data', 'katago', platform.binaryPath) + const modelTarget = join(root, 'data', 'katago', manifest.modelPath) + + const copiedBinary = await copyIfProvided(binarySource || binaryFallback, binaryTarget, `binary ${key}`) + const copiedModel = await copyIfProvided(modelSource || modelFallback, modelTarget, 'default model') + + if (!copiedBinary || !copiedModel) { + console.log('[prepare-katago-assets] No complete asset pair copied. This is OK for local development but release packaging should provide both assets.') + } +} + +main().catch((error) => { + console.error(`[prepare-katago-assets] ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/scripts/review_game.py b/scripts/review_game.py index 988447f..9a6cda0 100644 --- a/scripts/review_game.py +++ b/scripts/review_game.py @@ -92,7 +92,7 @@ def __init__(self, katago_bin, config_path, model_path, size): ) self.size = size - def query(self, moves, komi, max_visits, idx): + def query(self, moves, komi, max_visits, idx, allow_moves=None): payload = { "id": f"query-{idx}", "moves": moves, @@ -103,6 +103,8 @@ def query(self, moves, komi, max_visits, idx): "boardYSize": self.size, "maxVisits": max_visits, } + if allow_moves: + payload["allowMoves"] = allow_moves self.proc.stdin.write(json.dumps(payload) + "\n") self.proc.stdin.flush() line = self.proc.stdout.readline() @@ -132,7 +134,7 @@ def summarize_issue(issue, student_name): def build_markdown(info, student_name, student_color, issues, language, llm_text): if language == "en-US": lines = [ - f"# KataSensei Review: {info['black']} vs {info['white']}", + f"# GoMentor Review: {info['black']} vs {info['white']}", "", f"- Student: {student_name or 'auto'} ({student_color})", f"- Result: {info['result'] or 'Unknown'}", @@ -148,7 +150,7 @@ def build_markdown(info, student_name, student_color, issues, language, llm_text return "\n".join(lines) lines = [ - f"# KataSensei 复盘报告:{info['black']} vs {info['white']}", + f"# GoMentor 复盘报告:{info['black']} vs {info['white']}", "", f"- 学生:{student_name or '自动识别'}(执{ '黑' if student_color == 'B' else '白' })", f"- 结果:{info['result'] or '未知'}", @@ -336,10 +338,19 @@ def main(): if not move_infos: continue best = move_infos[0] - best_wr = float(best.get("winrate", 0.5)) * 100.0 - played_response = analyzer.query(moves[: index + 1], info["komi"], args.max_visits, f"played-{index}") - played_root = played_response.get("rootInfo", {}) - played_wr = float(played_root.get("winrate", 0.5)) * 100.0 + best_wr_black = float(best.get("winrate", 0.5)) * 100.0 + played_response = analyzer.query( + history, + info["komi"], + args.max_visits, + f"played-{index}", + allow_moves=[{"player": color, "moves": [played_move], "untilDepth": 1}], + ) + played_infos = played_response.get("moveInfos", []) + played_info = next((item for item in played_infos if item.get("move") == played_move), played_infos[0] if played_infos else {}) + played_wr_black = float(played_info.get("winrate", 0.5)) * 100.0 + best_wr = best_wr_black if color == "B" else 100.0 - best_wr_black + played_wr = played_wr_black if color == "B" else 100.0 - played_wr_black loss = max(0.0, best_wr - played_wr) if loss < args.min_winrate_drop: continue diff --git a/scripts/smoke_check_resources.mjs b/scripts/smoke_check_resources.mjs new file mode 100644 index 0000000..b98c485 --- /dev/null +++ b/scripts/smoke_check_resources.mjs @@ -0,0 +1,23 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +const root = process.cwd() +const knowledge = join(root, 'data', 'knowledge', 'p0-cards.json') +if (!existsSync(knowledge)) { + console.error('Missing data/knowledge/p0-cards.json') + process.exit(1) +} + +const platform = `${process.platform}-${process.arch}` +const binary = join(root, 'data', 'katago', 'bin', platform, process.platform === 'win32' ? 'katago.exe' : 'katago') +const model = join(root, 'data', 'katago', 'models', 'kata1-b18c384nbt-s9996604416-d4316597426.bin.gz') + +console.log(`Knowledge cards: ${knowledge}`) +console.log(`Expected KataGo binary: ${binary}`) +console.log(`Expected default model: ${model}`) +console.log('KataGo binary present:', existsSync(binary)) +console.log('Default model present:', existsSync(model)) + +if (!existsSync(binary) || !existsSync(model)) { + console.warn('KataGo runtime resources are missing. This is expected in source checkout, but not in release packaging.') +} diff --git a/scripts/teacher_llm_real_smoke.mjs b/scripts/teacher_llm_real_smoke.mjs new file mode 100644 index 0000000..dc8e58c --- /dev/null +++ b/scripts/teacher_llm_real_smoke.mjs @@ -0,0 +1,400 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const electronBin = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron') + +function freePort() { + return new Promise((resolve, reject) => { + const server = createServer() + server.listen(0, '127.0.0.1', () => { + const address = server.address() + server.close(() => { + if (address && typeof address === 'object') { + resolve(address.port) + } else { + reject(new Error('Cannot allocate local port')) + } + }) + }) + }) +} + +function startElectron({ cdpPort }) { + const child = spawn(electronBin, ['.'], { + cwd: root, + env: { + ...process.env, + GOMENTOR_REMOTE_DEBUGGING_PORT: String(cdpPort), + ELECTRON_ENABLE_LOGGING: '1' + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + child.stdout.on('data', (chunk) => process.stdout.write(`[electron] ${chunk}`)) + child.stderr.on('data', (chunk) => process.stderr.write(`[electron] ${chunk}`)) + return child +} + +async function waitForRenderer(cdpPort, timeoutMs = 45_000) { + const deadline = Date.now() + timeoutMs + let lastError = '' + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${cdpPort}/json/list`) + const pages = await response.json() + const page = pages.find((item) => item.type === 'page' && item.webSocketDebuggerUrl) + if (page) { + return page.webSocketDebuggerUrl + } + } catch (error) { + lastError = String(error) + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error(`Timed out waiting for Electron renderer CDP. ${lastError}`) +} + +async function evaluateInRenderer(wsUrl, expression, timeoutMs = 240_000) { + const socket = new WebSocket(wsUrl) + let nextId = 1 + const pending = new Map() + const opened = new Promise((resolve, reject) => { + socket.onopen = resolve + socket.onerror = reject + }) + socket.onmessage = (event) => { + const message = JSON.parse(event.data) + const item = pending.get(message.id) + if (!item) return + pending.delete(message.id) + if (message.error) { + item.reject(new Error(JSON.stringify(message.error))) + } else { + item.resolve(message.result) + } + } + await opened + + function send(method, params = {}) { + const id = nextId++ + socket.send(JSON.stringify({ id, method, params })) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error(`${method} timed out`)) + }, timeoutMs) + pending.set(id, { + resolve: (value) => { + clearTimeout(timer) + resolve(value) + }, + reject: (error) => { + clearTimeout(timer) + reject(error) + } + }) + }) + } + + await send('Runtime.enable') + const result = await send('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + timeout: timeoutMs + }) + socket.close() + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.text || JSON.stringify(result.exceptionDetails)) + } + return result.result.value +} + +const smokeExpression = ` +(async () => { + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + function gtpToPoint(move, size) { + if (!move || move.toLowerCase() === 'pass') return null; + const letters = 'ABCDEFGHJKLMNOPQRSTUVWXYZ'; + const col = letters.indexOf(move[0].toUpperCase()); + const row = size - Number(move.slice(1)); + if (col < 0 || Number.isNaN(row) || row < 0 || row >= size) return null; + return { row, col }; + } + function computeBoard(record, moveNumber) { + const board = Array.from({ length: record.boardSize }, () => Array.from({ length: record.boardSize }, () => '')); + for (const move of record.moves.slice(0, moveNumber)) { + if (move.row === null || move.col === null) continue; + board[move.row][move.col] = move.color; + } + return board; + } + function drawRoundedRect(ctx, x, y, width, height, radius) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + } + function renderBoardImage(record, moveNumber, analysis) { + const size = record.boardSize; + const canvas = document.createElement('canvas'); + canvas.width = 980; + canvas.height = 980; + const ctx = canvas.getContext('2d'); + const margin = 92; + const edge = 28; + const step = (canvas.width - margin * 2) / (size - 1); + const board = computeBoard(record, moveNumber); + const lastMove = record.moves[moveNumber - 1]; + const letters = 'ABCDEFGHJKLMNOPQRST'.split(''); + + ctx.fillStyle = '#eef1ed'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + drawRoundedRect(ctx, edge, edge, canvas.width - edge * 2, canvas.height - edge * 2, 18); + ctx.fillStyle = '#d4aa61'; + ctx.fill(); + ctx.save(); + ctx.globalAlpha = 0.13; + for (let i = 0; i < 42; i += 1) { + ctx.strokeStyle = i % 2 ? '#6e411d' : '#f0d28e'; + ctx.lineWidth = i % 5 === 0 ? 2 : 1; + ctx.beginPath(); + ctx.moveTo(edge + i * 24, edge); + ctx.bezierCurveTo(edge + i * 20, 280, edge + i * 28, 620, edge + i * 23, canvas.height - edge); + ctx.stroke(); + } + ctx.restore(); + + ctx.strokeStyle = 'rgba(31, 25, 17, 0.82)'; + for (let i = 0; i < size; i += 1) { + const p = margin + i * step; + ctx.lineWidth = i === 0 || i === size - 1 ? 2.3 : 1.25; + ctx.beginPath(); + ctx.moveTo(margin, p); + ctx.lineTo(canvas.width - margin, p); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(p, margin); + ctx.lineTo(p, canvas.height - margin); + ctx.stroke(); + } + ctx.fillStyle = 'rgba(26, 22, 17, 0.76)'; + for (const row of [3, 9, 15]) { + for (const col of [3, 9, 15]) { + ctx.beginPath(); + ctx.arc(margin + col * step, margin + row * step, 4.7, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.font = '600 18px Inter, Avenir Next, PingFang SC, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = 'rgba(44, 35, 22, 0.62)'; + for (let i = 0; i < size; i += 1) { + const p = margin + i * step; + ctx.fillText(letters[i], p, 56); + ctx.fillText(letters[i], p, canvas.height - 56); + ctx.fillText(String(size - i), 55, p); + ctx.fillText(String(size - i), canvas.width - 55, p); + } + + for (let row = 0; row < size; row += 1) { + for (let col = 0; col < size; col += 1) { + const stone = board[row][col]; + if (!stone) continue; + const x = margin + col * step; + const y = margin + row * step; + const radius = step * 0.47; + const gradient = ctx.createRadialGradient(x - radius * 0.28, y - radius * 0.34, radius * 0.14, x, y, radius); + if (stone === 'B') { + gradient.addColorStop(0, '#68707a'); + gradient.addColorStop(0.38, '#24272b'); + gradient.addColorStop(1, '#050607'); + } else { + gradient.addColorStop(0, '#ffffff'); + gradient.addColorStop(0.55, '#ede8dc'); + gradient.addColorStop(1, '#b9b2a5'); + } + ctx.save(); + ctx.shadowColor = 'rgba(35, 25, 13, 0.34)'; + ctx.shadowBlur = 8; + ctx.shadowOffsetY = 3; + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + if (lastMove?.row === row && lastMove?.col === col) { + ctx.strokeStyle = stone === 'B' ? '#f4eadb' : '#232323'; + ctx.lineWidth = 3.5; + ctx.beginPath(); + ctx.arc(x, y, radius * 0.38, 0, Math.PI * 2); + ctx.stroke(); + } + } + } + + const candidates = (analysis?.before?.topMoves ?? []).slice(0, 5); + const maxVisits = Math.max(...candidates.map((candidate) => candidate.visits ?? 0), 1); + const colors = ['#2f9662', '#3f7fa6', '#b58a2d', '#845bb5', '#637083']; + for (const [index, candidate] of candidates.entries()) { + const point = gtpToPoint(candidate.move, size); + if (!point) continue; + const x = margin + point.col * step; + const y = margin + point.row * step; + const share = clamp((candidate.visits ?? 0) / maxVisits, 0.18, 1); + const radius = step * (0.35 + share * 0.11); + ctx.save(); + ctx.shadowColor = 'rgba(0, 0, 0, 0.18)'; + ctx.shadowBlur = 8; + ctx.fillStyle = colors[index] ?? '#637083'; + ctx.beginPath(); + ctx.arc(x, y, radius, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(255, 253, 246, 0.92)'; + ctx.lineWidth = 2.4; + ctx.stroke(); + ctx.restore(); + ctx.fillStyle = '#fffaf1'; + ctx.font = '800 15px Inter, Avenir Next, sans-serif'; + ctx.fillText(String(index + 1), x, y - radius * 0.45); + ctx.font = '700 13px Inter, Avenir Next, sans-serif'; + ctx.fillText(Number(candidate.winrate ?? 0).toFixed(1) + '%', x, y - radius * 0.02); + ctx.font = '600 12px Inter, Avenir Next, sans-serif'; + ctx.fillText(String(candidate.visits ?? 0), x, y + radius * 0.42); + } + ctx.fillStyle = 'rgba(25, 22, 18, 0.72)'; + ctx.font = '600 20px Inter, Avenir Next, PingFang SC, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('Move ' + moveNumber + ' / ' + record.moves.length, margin, canvas.height - 30); + return canvas.toDataURL('image/png'); + } + + const dashboard = await window.gomentor.getDashboard(); + if (!dashboard.systemProfile?.hasLlmApiKey) { + throw new Error('No saved LLM API key. Configure settings before running real smoke.'); + } + if (!dashboard.games?.length) { + throw new Error('No public SGF games in the local library.'); + } + const probe = await window.gomentor.testLlmSettings({ llmBaseUrl: '', llmApiKey: '', llmModel: '' }); + const game = dashboard.games[0]; + const record = await window.gomentor.getGameRecord(game.id); + const moveNumber = Math.min(80, Math.max(1, record.moves.length)); + const analysis = await window.gomentor.analyzePosition({ gameId: game.id, moveNumber, maxVisits: 96 }); + const boardImageDataUrl = renderBoardImage(record, moveNumber, analysis); + const result = await window.gomentor.runTeacherTask({ + mode: 'current-move', + prompt: '这是一盘公开棋谱。请调用真实多模态能力,结合棋盘截图、KataGo 数据和本地知识库,给出当前手的结构化中文讲解。', + gameId: game.id, + moveNumber, + boardImageDataUrl, + prefetchedAnalysis: analysis + }); + const llmLog = result.toolLogs.find((log) => log.name === 'llm.multimodalTeacher'); + return { + baseUrl: dashboard.settings.llmBaseUrl, + model: dashboard.settings.llmModel, + gameTitle: game.title, + black: game.black, + white: game.white, + source: game.source, + moveNumber, + topMove: analysis.before?.topMoves?.[0]?.move ?? '', + candidateCount: analysis.before?.topMoves?.length ?? 0, + imageBytesApprox: Math.round(boardImageDataUrl.length * 0.75), + probeOk: probe.ok, + probeMessage: probe.message, + llmStatus: llmLog?.status ?? 'missing', + llmDetail: llmLog?.detail ?? '', + knowledgeCount: result.knowledge?.length ?? 0, + knowledgeMatchCount: result.knowledgeMatches?.length ?? 0, + recommendedProblemCount: result.recommendedProblems?.length ?? 0, + knowledgeMatches: (result.knowledgeMatches ?? []).slice(0, 3).map((match) => ({ + title: match.title, + matchType: match.matchType, + confidence: match.confidence + })), + recommendedProblems: (result.recommendedProblems ?? []).slice(0, 3).map((problem) => ({ + title: problem.title, + problemType: problem.problemType, + difficulty: problem.difficulty + })), + reportPath: result.reportPath ?? '', + headline: result.structuredResult?.headline ?? result.structured?.headline ?? '', + markdownPreview: String(result.markdown ?? '').slice(0, 220), + toolLogs: result.toolLogs.map((log) => ({ name: log.name, status: log.status, detail: log.detail })) + }; +})() +` + +function redactUrl(raw) { + try { + const url = new URL(raw) + return `${url.protocol}//${url.host}${url.pathname.replace(/\/$/, '')}` + } catch { + return '' + } +} + +async function main() { + const cdpPort = await freePort() + const child = startElectron({ cdpPort }) + try { + const wsUrl = await waitForRenderer(cdpPort) + const result = await evaluateInRenderer(wsUrl, smokeExpression) + assert.equal(result.probeOk, true, result.probeMessage) + assert.equal(result.llmStatus, 'done', result.llmDetail) + assert.ok(result.markdownPreview && !result.markdownPreview.includes('多模态 LLM 暂时不可用'), 'LLM should return usable teacher content') + assert.ok(result.candidateCount > 0, 'KataGo should return candidate moves') + assert.ok(result.knowledgeCount >= 2, 'Teacher runtime should retrieve local knowledge cards') + assert.ok(result.knowledgeMatchCount >= 1, 'Teacher runtime should return structured knowledge matches') + assert.ok(result.recommendedProblemCount >= 1, 'Teacher runtime should return recommended training problems') + assert.ok(result.reportPath, 'Teacher runtime should persist a report') + assert.ok(result.imageBytesApprox > 50_000, 'Teacher request should include a real board image, not a placeholder') + + console.log('Real Teacher LLM smoke passed') + console.log(JSON.stringify({ + baseUrl: redactUrl(result.baseUrl), + model: result.model, + gameTitle: result.gameTitle, + black: result.black, + white: result.white, + source: result.source, + moveNumber: result.moveNumber, + topMove: result.topMove, + candidateCount: result.candidateCount, + imageBytesApprox: result.imageBytesApprox, + probeOk: result.probeOk, + probeMessage: result.probeOk ? 'OK' : result.probeMessage, + knowledgeCount: result.knowledgeCount, + knowledgeMatchCount: result.knowledgeMatchCount, + recommendedProblemCount: result.recommendedProblemCount, + knowledgeMatches: result.knowledgeMatches, + recommendedProblems: result.recommendedProblems, + headline: result.headline, + markdownPreview: result.markdownPreview, + toolLogs: result.toolLogs + }, null, 2)) + } finally { + child.kill() + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/teacher_llm_smoke.mjs b/scripts/teacher_llm_smoke.mjs new file mode 100644 index 0000000..19fce58 --- /dev/null +++ b/scripts/teacher_llm_smoke.mjs @@ -0,0 +1,348 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { spawn } from 'node:child_process' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const root = resolve(new URL('..', import.meta.url).pathname) +const electronBin = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron') +const tinyPng = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/luzK4wAAAABJRU5ErkJggg==' + +function freePort() { + return new Promise((resolvePromise, reject) => { + const server = createServer() + server.listen(0, '127.0.0.1', () => { + const address = server.address() + server.close(() => { + if (address && typeof address === 'object') { + resolvePromise(address.port) + } else { + reject(new Error('Cannot allocate local port')) + } + }) + }) + }) +} + +function textFromPart(part) { + if (typeof part === 'string') return part + if (!part || typeof part !== 'object') return '' + if (part.type === 'text') return String(part.text ?? '') + return '' +} + +function textFromMessage(message) { + const content = message?.content + if (typeof content === 'string') return content + if (Array.isArray(content)) return content.map(textFromPart).join('\n') + return '' +} + +function hasImage(message) { + return Array.isArray(message?.content) && message.content.some((part) => part?.type === 'image_url') +} + +async function startMockLlmServer(port) { + const requests = [] + const server = createServer((request, response) => { + let raw = '' + request.setEncoding('utf8') + request.on('data', (chunk) => { + raw += chunk + }) + request.on('end', () => { + const body = raw ? JSON.parse(raw) : {} + requests.push(body) + const allText = (body.messages ?? []).map(textFromMessage).join('\n') + const isProbe = allText.includes('请只回答 OK') || allText.includes('只输出 OK') + const content = isProbe ? 'OK' : JSON.stringify({ + taskType: 'current-move', + headline: '本手要先抢全局最大点', + summary: 'KataGo 证据显示,当前局面的重点是比较一选和实战手的效率差。', + keyMistakes: [{ + moveNumber: 8, + color: 'W', + played: 'Q4', + recommended: 'D16', + errorType: '方向', + severity: 'mistake', + evidence: 'mock LLM 已收到 KataGo facts、知识卡和棋盘图片。', + explanation: '局部补棋价值低于全局大场,需要先看全盘。' + }], + correctThinking: ['先比较 KataGo 一选和实战手的胜率/目差', '再把推荐点放回全局厚薄判断'], + drills: ['复盘 5 个布局阶段的一选大场', '每手棋先说出全局最大价值点'], + followupQuestions: ['展开一选变化', '给我 3 道同类训练题'], + knowledgeCardIds: ['direction_global_over_local'], + profileUpdates: { + errorTypes: ['方向'], + patterns: ['局部过重'], + trainingFocus: ['布局方向感'] + } + }) + + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ + choices: [{ finish_reason: 'stop', message: { content } }], + usage: { prompt_tokens: 1000, completion_tokens: 120, total_tokens: 1120 } + })) + }) + }) + + await new Promise((resolvePromise) => server.listen(port, '127.0.0.1', resolvePromise)) + return { + requests, + close: () => new Promise((resolvePromise, reject) => { + server.close((error) => error ? reject(error) : resolvePromise()) + }) + } +} + +async function seedSmokeHome(homeRoot) { + const appHome = join(homeRoot, '.gomentor') + const libraryDir = join(appHome, 'library', 'upload') + await mkdir(libraryDir, { recursive: true }) + const sgfPath = join(libraryDir, 'teacher-smoke.sgf') + const sgf = [ + '(;GM[1]FF[4]CA[UTF-8]SZ[19]KM[7.5]', + 'PB[SmokeBlack]PW[SmokeWhite]RE[B+R]DT[2026-04-25]GN[Teacher LLM Smoke]', + ';B[pd];W[dd];B[qp];W[dp];B[fc];W[cf];B[nc];W[qq];B[oq];W[dc];B[cn];W[fq])' + ].join('') + await writeFile(sgfPath, sgf, 'utf8') + await writeFile(join(appHome, 'library.json'), JSON.stringify({ + games: [{ + id: 'teacher-smoke-game', + title: 'SmokeBlack vs SmokeWhite', + event: '', + black: 'SmokeBlack', + white: 'SmokeWhite', + result: 'B+R', + date: '2026-04-25', + source: 'upload', + sourceLabel: 'Smoke fixture', + filePath: sgfPath, + createdAt: '2026-04-25T00:00:00.000Z' + }] + }, null, 2), 'utf8') + return { appHome, sgfPath } +} + +function startElectron({ homeRoot, cdpPort }) { + const child = spawn(electronBin, ['.'], { + cwd: root, + env: { + ...process.env, + GOMENTOR_APP_HOME: join(homeRoot, '.gomentor'), + GOMENTOR_REMOTE_DEBUGGING_PORT: String(cdpPort), + ELECTRON_ENABLE_LOGGING: '1' + }, + stdio: ['ignore', 'pipe', 'pipe'] + }) + child.stdout.on('data', (chunk) => process.stdout.write(`[electron] ${chunk}`)) + child.stderr.on('data', (chunk) => process.stderr.write(`[electron] ${chunk}`)) + return child +} + +async function waitForRenderer(cdpPort, timeoutMs = 45_000) { + const deadline = Date.now() + timeoutMs + let lastError = '' + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${cdpPort}/json/list`) + const pages = await response.json() + const page = pages.find((item) => item.type === 'page' && item.webSocketDebuggerUrl) + if (page) { + return page.webSocketDebuggerUrl + } + } catch (error) { + lastError = String(error) + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 500)) + } + throw new Error(`Timed out waiting for Electron renderer CDP. ${lastError}`) +} + +async function evaluateInRenderer(wsUrl, expression, timeoutMs = 180_000) { + const socket = new WebSocket(wsUrl) + let nextId = 1 + const pending = new Map() + const opened = new Promise((resolvePromise, reject) => { + socket.onopen = resolvePromise + socket.onerror = reject + }) + socket.onmessage = (event) => { + const message = JSON.parse(event.data) + const item = pending.get(message.id) + if (!item) return + pending.delete(message.id) + if (message.error) { + item.reject(new Error(JSON.stringify(message.error))) + } else { + item.resolve(message.result) + } + } + await opened + + function send(method, params = {}) { + const id = nextId++ + socket.send(JSON.stringify({ id, method, params })) + return new Promise((resolvePromise, reject) => { + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error(`${method} timed out`)) + }, timeoutMs) + pending.set(id, { + resolve: (value) => { + clearTimeout(timer) + resolvePromise(value) + }, + reject: (error) => { + clearTimeout(timer) + reject(error) + } + }) + }) + } + + await send('Runtime.enable') + const result = await send('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + timeout: timeoutMs + }) + socket.close() + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.text || JSON.stringify(result.exceptionDetails)) + } + return result.result.value +} + +async function main() { + const homeRoot = await mkdtemp(join(tmpdir(), 'gomentor-teacher-home-')) + const mockPort = await freePort() + const cdpPort = await freePort() + const mock = await startMockLlmServer(mockPort) + await seedSmokeHome(homeRoot) + + const child = startElectron({ homeRoot, cdpPort }) + try { + const wsUrl = await waitForRenderer(cdpPort) + const result = await evaluateInRenderer(wsUrl, ` + (async () => { + const tinyPng = ${JSON.stringify(tinyPng)}; + const baseUrl = 'http://127.0.0.1:${mockPort}/v1'; + const probe = await window.gomentor.testLlmSettings({ + llmBaseUrl: baseUrl, + llmApiKey: 'smoke-key', + llmModel: 'gpt-5.4' + }); + if (!probe.ok) throw new Error('LLM probe failed: ' + probe.message); + const dashboard = await window.gomentor.updateSettings({ + llmBaseUrl: baseUrl, + llmApiKey: 'smoke-key', + llmModel: 'gpt-5.4', + defaultPlayerName: 'SmokeBlack' + }); + const game = dashboard.games[0]; + if (!game) throw new Error('No smoke game loaded'); + const record = await window.gomentor.getGameRecord(game.id); + const moveNumber = Math.min(8, record.moves.length); + const analysis = await window.gomentor.analyzePosition({ + gameId: game.id, + moveNumber, + maxVisits: 24 + }); + const result = await window.gomentor.runTeacherTask({ + mode: 'current-move', + prompt: '请分析当前手,输出结构化 JSON 讲解。', + gameId: game.id, + moveNumber, + boardImageDataUrl: tinyPng, + prefetchedAnalysis: analysis + }); + return { + probe, + gameTitle: game.title, + moveNumber, + bestMove: analysis.before.topMoves[0]?.move || '', + candidateCount: analysis.before.topMoves.length, + resultTitle: result.title, + markdown: result.markdown, + structured: result.structuredResult || result.structured, + knowledgeCount: result.knowledge?.length || 0, + knowledgeMatchCount: result.knowledgeMatches?.length || 0, + recommendedProblemCount: result.recommendedProblems?.length || 0, + knowledgeMatches: (result.knowledgeMatches ?? []).slice(0, 3).map((match) => ({ + title: match.title, + matchType: match.matchType, + confidence: match.confidence + })), + recommendedProblems: (result.recommendedProblems ?? []).slice(0, 3).map((problem) => ({ + title: problem.title, + problemType: problem.problemType, + difficulty: problem.difficulty + })), + toolLogs: result.toolLogs.map((log) => ({ name: log.name, status: log.status, detail: log.detail })), + reportPath: result.reportPath || '' + }; + })() + `) + + assert.equal(result.probe.ok, true) + assert.equal(result.structured?.headline, '本手要先抢全局最大点') + assert.equal(result.structured?.taskType, 'current-move') + assert.ok(result.candidateCount > 0, 'KataGo should return candidate moves') + assert.ok(result.bestMove, 'KataGo should return a best move') + assert.ok(result.knowledgeCount >= 2, 'Teacher runtime should retrieve local knowledge cards') + assert.ok(result.knowledgeMatchCount >= 1, 'Teacher runtime should return structured knowledge matches') + assert.ok(result.recommendedProblemCount >= 1, 'Teacher runtime should return recommended training problems') + assert.ok(result.markdown.includes('本手要先抢全局最大点') || result.markdown.includes('KataGo'), 'Teacher markdown should be visible') + for (const tool of ['board.captureTeachingImage', 'katago.analyzePosition', 'knowledge.searchLocal', 'llm.multimodalTeacher', 'studentProfile.write']) { + assert.equal(result.toolLogs.find((log) => log.name === tool)?.status, 'done', `${tool} should finish`) + } + assert.ok(result.reportPath, 'Teacher runtime should persist a report') + await stat(result.reportPath) + + const probeRequest = mock.requests.find((body) => (body.messages ?? []).some((message) => { + const text = textFromMessage(message) + return text.includes('请只回答 OK') || text.includes('只输出 OK') + })) + const teacherRequest = mock.requests.find((body) => (body.messages ?? []).some((message) => textFromMessage(message).includes('katagoFacts'))) + assert.ok(probeRequest, 'Mock LLM should receive probe request') + assert.ok(teacherRequest, 'Mock LLM should receive teacher request with KataGo facts') + assert.ok((teacherRequest.messages ?? []).some(hasImage), 'Teacher request should include board image') + const teacherPayload = (teacherRequest.messages ?? []).map(textFromMessage).join('\n') + assert.match(teacherPayload, /katagoFacts/) + assert.match(teacherPayload, /knowledgePacket/) + assert.match(teacherPayload, /knowledgeMatches/) + assert.match(teacherPayload, /recommendedProblems/) + assert.match(teacherPayload, /partial 匹配只能说/) + assert.match(teacherPayload, /studentProfile/) + + console.log('Teacher LLM smoke passed') + console.log(JSON.stringify({ + gameTitle: result.gameTitle, + moveNumber: result.moveNumber, + bestMove: result.bestMove, + candidateCount: result.candidateCount, + knowledgeCount: result.knowledgeCount, + knowledgeMatchCount: result.knowledgeMatchCount, + recommendedProblemCount: result.recommendedProblemCount, + knowledgeMatches: result.knowledgeMatches, + recommendedProblems: result.recommendedProblems, + toolLogs: result.toolLogs + }, null, 2)) + } finally { + child.kill() + await mock.close().catch(() => {}) + await rm(homeRoot, { recursive: true, force: true }).catch(() => {}) + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/verify_release_artifacts.mjs b/scripts/verify_release_artifacts.mjs new file mode 100755 index 0000000..8a5677b --- /dev/null +++ b/scripts/verify_release_artifacts.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' + +const root = process.cwd() +const args = new Set(process.argv.slice(2)) +const modeArg = process.argv.find((arg) => arg.startsWith('--mode=')) +const mode = modeArg ? modeArg.split('=')[1] : (args.has('--release') ? 'release' : 'dev') +const releaseRoot = join(root, 'release') +const packageVersion = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version +const versionReleaseDir = join(releaseRoot, packageVersion) +const releaseDir = existsSync(versionReleaseDir) ? versionReleaseDir : releaseRoot +const minSizeBytes = Number(process.env.GOMENTOR_MIN_ARTIFACT_BYTES ?? 1024 * 1024) + +function walk(dir) { + if (!existsSync(dir)) return [] + const out = [] + for (const name of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, name.name) + if (name.isDirectory()) out.push(...walk(full)) + else out.push(full) + } + return out +} + +const files = walk(releaseDir) +function isPackagedArtifact(file) { + const rel = file.replace(root + '/', '') + if ( + rel.includes('.app/') || + rel.includes('-unpacked/') || + rel.includes('/mac/') || + rel.includes('/mac-arm64/') + ) { + return false + } + return /\.(dmg|zip|exe|AppImage|deb|tar\.gz)$/i.test(file) +} + +const artifacts = files.filter(isPackagedArtifact) +const macArm64Dmg = artifacts.filter((file) => /mac-arm64\.dmg$/i.test(file)) +const macX64Dmg = artifacts.filter((file) => /mac-x64\.dmg$/i.test(file)) +const winX64Installer = artifacts.filter((file) => /win-x64\.exe$/i.test(file) && !/portable/i.test(file)) +const winX64PortableZip = artifacts.filter((file) => /win-x64-portable\.zip$/i.test(file)) +const winPortableExe = artifacts.filter((file) => /win-x64-portable\.exe$/i.test(file)) +const winArm64 = artifacts.filter((file) => /win-arm64/i.test(file)) +const tiny = artifacts.filter((file) => statSync(file).size < minSizeBytes) + +console.log(`Release artifact smoke (${mode})`) +console.log(`packageVersion=${packageVersion}`) +console.log(`scanDir=${releaseDir.replace(root + '/', '')}`) +console.log(`Found ${artifacts.length} artifact candidates`) +for (const artifact of artifacts) { + const size = statSync(artifact).size + console.log(`- ${artifact.replace(root + '/', '')} (${Math.round(size / 1024)} KB)`) +} + +const failures = [] +const warnings = [] +if (artifacts.length === 0) { + if (mode === 'release') failures.push('No release artifacts found') + else warnings.push('No release artifacts found in dev mode') +} +if (mode === 'release') { + if (!existsSync(versionReleaseDir)) failures.push(`No release directory found for package version ${packageVersion}`) + if (macArm64Dmg.length === 0) failures.push('No macOS arm64 DMG found') + if (macX64Dmg.length === 0) failures.push('No macOS x64 DMG found') + if (winX64Installer.length === 0) failures.push('No Windows x64 installer found') + if (winX64PortableZip.length === 0) failures.push('No Windows x64 portable ZIP found') + if (winPortableExe.length > 0) failures.push(`Windows portable artifact must be a ZIP, not an EXE: ${winPortableExe.map((file) => file.replace(root + '/', '')).join(', ')}`) + if (winArm64.length > 0) failures.push(`Windows ARM64 artifacts are not supported for P0 beta: ${winArm64.map((file) => file.replace(root + '/', '')).join(', ')}`) + if (tiny.length > 0) failures.push(`Artifact too small: ${tiny.map((file) => file.replace(root + '/', '')).join(', ')}`) +} else if (tiny.length > 0) { + warnings.push(`Artifact too small: ${tiny.map((file) => file.replace(root + '/', '')).join(', ')}`) +} + +for (const warning of warnings) console.log(`! ${warning}`) +for (const failure of failures) console.log(`✗ ${failure}`) +console.log(`Summary: ${Math.max(0, artifacts.length - tiny.length)} artifact(s), ${warnings.length} warning(s), ${failures.length} failure(s)`) +process.exit(failures.length > 0 ? 1 : 0) diff --git a/src/main/index.ts b/src/main/index.ts index 8eea0ce..2f2a468 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,16 +1,44 @@ -import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, Menu, shell, type IpcMainInvokeEvent, type MenuItemConstructorOptions } from 'electron' import { isAbsolute, relative, resolve, join } from 'node:path' import { appHome, findGame, getGames, getSettings, hasLlmApiKey, replaceSettings, setSettings, upsertGames } from './lib/store' -import type { AnalyzeGameQuickRequest, AnalyzePositionRequest, AppSettings, DashboardData, FoxSyncRequest, LlmSettingsTestRequest, ReviewRequest, TeacherRunRequest } from './lib/types' +import type { AnalyzeGameQuickRequest, AnalyzePositionRequest, AppSettings, DashboardData, FoxSyncRequest, KataGoAssetInstallRequest, KataGoBenchmarkRequest, LlmModelsListRequest, LlmSettingsTestRequest, ReviewRequest, TeacherRunRequest } from './lib/types' import { importSgfFile, readGameRecord } from './services/sgf' -import { syncFoxGames } from './services/fox' +import { ensureFoxGameDownloaded, syncFoxGames } from './services/fox' import { runReview } from './services/review' import { applyDetectedDefaults, detectSystemProfile } from './services/systemProfile' import { runTeacherTask } from './services/teacherAgent' -import { testLlmSettings } from './services/llm' -import { analyzeGameQuick, analyzePosition } from './services/katago' +import { listLlmModels, testLlmSettings } from './services/llm' +import { analyzeGameQuick, analyzePosition, analyzePositionWithProgress } from './services/katago' +import { benchmarkKataGo } from './services/katagoBenchmark' +import { collectDiagnostics } from './services/diagnostics' +import { searchKnowledgeCards } from './services/knowledge/searchLocal' +import { inspectKataGoAssets, installOfficialKataGoModel } from './services/katago/katagoAssets' +import { bindFoxGamesToStudent, bindSgfGameToStudent, suggestStudentBindings } from './services/library/studentBinding' +import { inspectReleaseReadiness } from './services/release/readiness' +import { + attachGameToStudent, + listStudents, + readStudentForGame, + resolveStudentByFoxNickname, + resolveStudentByName, + upsertStudentAlias +} from './services/studentProfile' let mainWindow: BrowserWindow | null = null +type DesktopCommand = + | 'open-command-palette' + | 'open-settings' + | 'import-sgf' + | 'analyze-current' + | 'analyze-game' + | 'analyze-recent' + | 'toggle-library' + | 'open-ui-gallery' + +const remoteDebuggingPort = process.env.GOMENTOR_REMOTE_DEBUGGING_PORT +if (remoteDebuggingPort && /^\d+$/.test(remoteDebuggingPort)) { + app.commandLine.appendSwitch('remote-debugging-port', remoteDebuggingPort) +} function assetPath(fileName: string): string { return join(__dirname, '../../assets', fileName) @@ -21,20 +49,41 @@ function assertManagedPath(filePath: string): string { const target = resolve(filePath) const rel = relative(root, target) if (rel.startsWith('..') || isAbsolute(rel)) { - throw new Error('只能打开 KataSensei 管理目录中的文件') + throw new Error('只能打开 GoMentor 管理目录中的文件') } return target } +function safeSendToRenderer(event: IpcMainInvokeEvent, channel: string, payload: unknown): boolean { + if (event.sender.isDestroyed()) { + return false + } + try { + event.sender.send(channel, payload) + return true + } catch (error) { + if (!String(error).includes('Object has been destroyed')) { + console.warn(`Failed to send renderer event "${channel}"`, error) + } + return false + } +} + async function createWindow(): Promise { mainWindow = new BrowserWindow({ width: 1460, height: 940, minWidth: 1180, minHeight: 760, - title: 'KataSensei', + title: 'GoMentor', icon: assetPath('icon.png'), backgroundColor: '#0f1115', + ...(process.platform === 'darwin' + ? { + titleBarStyle: 'hiddenInset' as const, + trafficLightPosition: { x: 18, y: 18 } + } + : {}), webPreferences: { preload: join(__dirname, '../preload/index.mjs'), contextIsolation: true, @@ -55,6 +104,77 @@ async function createWindow(): Promise { } } +function sendDesktopCommand(command: DesktopCommand): void { + if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed()) { + return + } + mainWindow.webContents.send('desktop:command', command) +} + +function buildApplicationMenu(): void { + const template: MenuItemConstructorOptions[] = [ + ...(process.platform === 'darwin' + ? [{ + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { label: 'Preferences...', accelerator: 'Command+,', click: () => sendDesktopCommand('open-settings') }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' } + ] + } satisfies MenuItemConstructorOptions] + : []), + { + label: 'File', + submenu: [ + { label: 'Import SGF Game Record...', accelerator: 'CommandOrControl+O', click: () => sendDesktopCommand('import-sgf') }, + { type: 'separator' }, + { label: 'Command Palette...', accelerator: 'CommandOrControl+K', click: () => sendDesktopCommand('open-command-palette') }, + { label: 'Settings...', accelerator: process.platform === 'darwin' ? 'Command+,' : 'Control+,', click: () => sendDesktopCommand('open-settings') }, + ...(process.platform === 'darwin' ? [] : [{ type: 'separator' as const }, { role: 'quit' as const }]) + ] + }, + { + label: 'Analyze', + submenu: [ + { label: 'Analyze Current Move', accelerator: 'CommandOrControl+1', click: () => sendDesktopCommand('analyze-current') }, + { label: 'Analyze Full Game', accelerator: 'CommandOrControl+2', click: () => sendDesktopCommand('analyze-game') }, + { label: 'Analyze Recent 10 Games', accelerator: 'CommandOrControl+3', click: () => sendDesktopCommand('analyze-recent') } + ] + }, + { + label: 'View', + submenu: [ + { label: 'Toggle Library', accelerator: 'CommandOrControl+B', click: () => sendDesktopCommand('toggle-library') }, + { label: 'Open UI Gallery', accelerator: 'CommandOrControl+Shift+G', click: () => sendDesktopCommand('open-ui-gallery') }, + { type: 'separator' }, + { role: 'reload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' } + ] + }, + { + label: 'Window', + submenu: [ + { role: 'minimize' }, + { role: 'zoom' }, + ...(process.platform === 'darwin' ? [{ type: 'separator' as const }, { role: 'front' as const }] : [{ role: 'close' as const }]) + ] + } + ] + Menu.setApplicationMenu(Menu.buildFromTemplate(template)) +} + async function dashboard(): Promise { const hydratedSettings = await applyDetectedDefaults(getSettings()) replaceSettings(hydratedSettings) @@ -75,6 +195,7 @@ app.whenReady().then(() => { if (process.platform === 'darwin') { app.dock?.setIcon(assetPath('icon.png')) } + buildApplicationMenu() ipcMain.handle('dashboard:get', async () => dashboard()) @@ -89,17 +210,30 @@ app.whenReady().then(() => { return dashboard() }) - ipcMain.handle('library:import', async () => { - const picked = await dialog.showOpenDialog({ + ipcMain.handle('library:import', async (event) => { + const owner = BrowserWindow.fromWebContents(event.sender) ?? mainWindow ?? undefined + const dialogOptions: Electron.OpenDialogOptions = { + title: '导入棋谱 SGF 文件', + buttonLabel: '导入棋谱', properties: ['openFile', 'multiSelections'], filters: [{ name: 'SGF files', extensions: ['sgf'] }] - }) + } + const picked = owner + ? await dialog.showOpenDialog(owner, dialogOptions) + : await dialog.showOpenDialog(dialogOptions) if (picked.canceled) { - return dashboard() + return { dashboard: await dashboard(), imported: [] } } const imported = picked.filePaths.map((filePath) => importSgfFile(filePath, 'upload', 'Local upload')) upsertGames(imported) - return dashboard() + const defaultPlayer = getSettings().defaultPlayerName.trim() + if (defaultPlayer) { + const student = resolveStudentByName(defaultPlayer, 'sgf') + for (const game of imported) { + attachGameToStudent(game.id, student.studentId) + } + } + return { dashboard: await dashboard(), imported } }) ipcMain.handle('library:record', async (_event, gameId: string) => { @@ -107,30 +241,80 @@ app.whenReady().then(() => { if (!game) { throw new Error(`找不到棋谱: ${gameId}`) } - return readGameRecord(game) + const readyGame = await ensureFoxGameDownloaded(game) + return readGameRecord(readyGame) }) ipcMain.handle('fox:sync', async (_event, payload: FoxSyncRequest) => { const result = await syncFoxGames(payload) upsertGames(result.saved) - return { dashboard: await dashboard(), result } + const student = await bindFoxGamesToStudent({ + foxNickname: result.nickname || payload.keyword, + gameIds: result.saved.map((game) => game.id), + aliases: [result.nickname, payload.keyword].filter(Boolean) + }) + return { dashboard: await dashboard(), result, student } }) + ipcMain.handle('diagnostics:get', async () => collectDiagnostics()) + ipcMain.handle('katago-assets:inspect', async () => inspectKataGoAssets()) + ipcMain.handle('katago-assets:install-official-model', async (event, payload: KataGoAssetInstallRequest | undefined) => + installOfficialKataGoModel(payload ?? {}, (progress) => { + safeSendToRenderer(event, 'katago-assets:install-progress', progress) + }) + ) + ipcMain.handle('student:list', async () => listStudents()) + ipcMain.handle('student:suggest-bindings', async (_event, payload) => suggestStudentBindings(payload)) + ipcMain.handle('student:bind-sgf-game', async (_event, payload) => bindSgfGameToStudent(payload)) + ipcMain.handle('student:bind-fox-games', async (_event, payload) => bindFoxGamesToStudent(payload)) + ipcMain.handle('student:for-game', async (_event, gameId: string) => readStudentForGame(gameId)) + ipcMain.handle('students:list', async () => listStudents()) + ipcMain.handle('students:resolve-fox', async (_event, nickname: string) => resolveStudentByFoxNickname(nickname)) + ipcMain.handle('students:attach-game', async (_event, payload: { gameId: string; studentId: string }) => attachGameToStudent(payload.gameId, payload.studentId)) + ipcMain.handle('students:alias', async (_event, payload: { studentId: string; alias: string }) => upsertStudentAlias(payload.studentId, payload.alias)) + ipcMain.handle('knowledge:search', async (_event, payload) => searchKnowledgeCards(payload)) ipcMain.handle('review:start', async (_event, payload: ReviewRequest) => runReview(payload)) ipcMain.handle('katago:analyze-position', async (_event, payload: AnalyzePositionRequest) => analyzePosition(payload.gameId, payload.moveNumber, payload.maxVisits ?? 500) ) + ipcMain.handle('katago:analyze-position-stream', async (event, payload: AnalyzePositionRequest) => + analyzePositionWithProgress( + payload.gameId, + payload.moveNumber, + payload.maxVisits ?? 500, + (analysis, isFinal) => { + safeSendToRenderer(event, 'katago:analyze-position-progress', { + runId: payload.runId, + gameId: payload.gameId, + moveNumber: payload.moveNumber, + analysis, + isFinal + }) + }, + payload.reportDuringSearchEvery ?? 0.2 + ) + ) ipcMain.handle('katago:analyze-game-quick', async (event, payload: AnalyzeGameQuickRequest) => - analyzeGameQuick(payload.gameId, payload.maxVisits ?? 12, (progress) => { - event.sender.send('katago:analyze-game-quick-progress', { + analyzeGameQuick(payload.gameId, payload.maxVisits, (progress) => { + safeSendToRenderer(event, 'katago:analyze-game-quick-progress', { ...progress, runId: payload.runId, gameId: payload.gameId }) + }, { + refineVisits: payload.refineVisits, + refineTopN: payload.refineTopN + }) + ) + ipcMain.handle('katago:benchmark', async (_event, payload: KataGoBenchmarkRequest | undefined) => benchmarkKataGo(payload ?? {})) + ipcMain.handle('teacher:run', async (event, payload: TeacherRunRequest) => + runTeacherTask(payload, (progress) => { + safeSendToRenderer(event, 'teacher:run-progress', progress) }) ) - ipcMain.handle('teacher:run', async (_event, payload: TeacherRunRequest) => runTeacherTask(payload)) ipcMain.handle('llm:test', async (_event, payload: LlmSettingsTestRequest) => testLlmSettings(payload)) + ipcMain.handle('llm:list-models', async (_event, payload: LlmModelsListRequest) => listLlmModels(payload)) + ipcMain.handle('release:readiness', async () => inspectReleaseReadiness()) ipcMain.handle('path:open', async (_event, filePath: string) => shell.showItemInFolder(assertManagedPath(filePath))) createWindow().catch((error) => { diff --git a/src/main/lib/errorCodes.ts b/src/main/lib/errorCodes.ts new file mode 100644 index 0000000..60fd896 --- /dev/null +++ b/src/main/lib/errorCodes.ts @@ -0,0 +1,62 @@ +export type GoMentorErrorCode = + | 'APP_HOME_NOT_WRITABLE' + | 'KATAGO_BINARY_MISSING' + | 'KATAGO_BINARY_NOT_EXECUTABLE' + | 'KATAGO_MODEL_MISSING' + | 'KATAGO_CONFIG_FAILED' + | 'KATAGO_PROCESS_FAILED' + | 'LLM_PROXY_NOT_CONFIGURED' + | 'LLM_PROXY_UNREACHABLE' + | 'LLM_IMAGE_UNSUPPORTED' + | 'LLM_EMPTY_RESPONSE' + | 'FOX_SYNC_FAILED' + | 'SGF_PARSE_FAILED' + | 'PROFILE_WRITE_FAILED' + | 'KNOWLEDGE_LOAD_FAILED' + | 'UNKNOWN_ERROR' + +export interface UserFacingError { + code: GoMentorErrorCode + title: string + message: string + action?: string + technicalDetail?: string +} + +export function toUserFacingError(error: unknown, fallbackCode: GoMentorErrorCode = 'UNKNOWN_ERROR'): UserFacingError { + const text = error instanceof Error ? error.message : String(error) + if (/katago/i.test(text) && /model|network|bin\.gz/i.test(text)) { + return { + code: 'KATAGO_MODEL_MISSING', + title: 'KataGo 模型缺失', + message: '应用没有找到默认 KataGo 模型,无法进行围棋局面分析。', + action: '请重新安装完整安装包,或在设置中选择可用模型文件。', + technicalDetail: text + } + } + if (/katago/i.test(text) && /binary|executable|spawn|ENOENT/i.test(text)) { + return { + code: 'KATAGO_BINARY_MISSING', + title: 'KataGo 引擎缺失', + message: '应用没有找到内置 KataGo 二进制,无法运行引擎分析。', + action: '请重新安装完整安装包,或检查安装包资源是否被杀毒软件隔离。', + technicalDetail: text + } + } + if (/LLM|proxy|chat\/completions|api key|Authorization/i.test(text)) { + return { + code: 'LLM_PROXY_UNREACHABLE', + title: 'Claude 兼容代理不可用', + message: '老师讲解需要连接到你配置的 Claude 兼容代理。', + action: '请在设置里检查 Base URL、API Key 和模型名,并运行图片测试。', + technicalDetail: text + } + } + return { + code: fallbackCode, + title: '发生未知错误', + message: '当前任务没有完成。你可以展开技术详情定位问题。', + action: '建议先运行诊断,确认 KataGo、模型和 Claude 兼容代理均可用。', + technicalDetail: text + } +} diff --git a/src/main/lib/store.ts b/src/main/lib/store.ts index 7cfbefe..1efe8a4 100644 --- a/src/main/lib/store.ts +++ b/src/main/lib/store.ts @@ -4,7 +4,7 @@ import { mkdirSync } from 'node:fs' import { join } from 'node:path' import type { AppSettings, LibraryGame } from './types' -export const appHome = join(app.getPath('home'), '.katasensei') +export const appHome = process.env.GOMENTOR_APP_HOME || join(app.getPath('home'), '.gomentor') export const libraryDir = join(appHome, 'library') export const reviewsDir = join(appHome, 'reviews') export const cacheDir = join(appHome, 'cache') @@ -19,6 +19,13 @@ const defaults: AppSettings = { katagoConfig: '', katagoModel: '', katagoModelPreset: 'official-b18-recommended', + katagoAnalysisThreads: 0, + katagoSearchThreadsPerAnalysisThread: 1, + katagoMaxBatchSize: 32, + katagoCacheSizePowerOfTwo: 20, + katagoBenchmarkThreads: 0, + katagoBenchmarkVisitsPerSecond: 0, + katagoBenchmarkUpdatedAt: '', pythonBin: 'python3', llmBaseUrl: 'https://api.openai.com/v1', llmApiKey: '', diff --git a/src/main/lib/types.ts b/src/main/lib/types.ts index 56fa9fd..a8d39aa 100644 --- a/src/main/lib/types.ts +++ b/src/main/lib/types.ts @@ -5,6 +5,13 @@ export interface AppSettings { katagoConfig: string katagoModel: string katagoModelPreset: KataGoModelPresetId + katagoAnalysisThreads: number + katagoSearchThreadsPerAnalysisThread: number + katagoMaxBatchSize: number + katagoCacheSizePowerOfTwo: number + katagoBenchmarkThreads: number + katagoBenchmarkVisitsPerSecond: number + katagoBenchmarkUpdatedAt: string pythonBin: string llmBaseUrl: string llmApiKey: string @@ -13,16 +20,21 @@ export interface AppSettings { defaultPlayerName: string } -export type KataGoModelPresetId = 'official-b18-recommended' | 'official-b28-strong' +export type KataGoModelPresetId = string export interface KataGoModelPreset { id: KataGoModelPresetId label: string badge: string + group: string + blockSize: string + speedTier: 'fast' | 'balanced' | 'strong' | 'maximum' + sizeHint: string description: string networkName: string fileName: string sourceUrl: string + downloadUrl?: string recommended: boolean } @@ -53,6 +65,10 @@ export interface LibraryGame { sourceLabel: string filePath: string createdAt: string + downloadStatus?: 'remote' | 'downloaded' + remoteId?: string + remoteUid?: string + moveCount?: number } export type StoneColor = 'B' | 'W' @@ -100,6 +116,104 @@ export interface FoxSyncResult { saved: LibraryGame[] } +export interface LibraryImportResult { + dashboard: DashboardData + imported: LibraryGame[] +} + +export interface FoxSyncResponse { + dashboard: DashboardData + result: FoxSyncResult + student?: StudentProfile +} + +export interface KataGoAssetStatus { + platformKey: string + manifestFound: boolean + binaryPath: string + binaryFound: boolean + binaryExecutable: boolean + modelPath: string + modelFound: boolean + modelDisplayName: string + ready: boolean + detail: string +} + +export interface KataGoBenchmarkRequest { + visits?: number + numPositions?: number + secondsPerMove?: number + threads?: number[] +} + +export interface KataGoBenchmarkThreadResult { + threads: number + visitsPerSecond: number +} + +export interface KataGoBenchmarkResult { + recommendedThreads: number + visitsPerSecond: number + tested: KataGoBenchmarkThreadResult[] + analysisThreads: number + searchThreadsPerAnalysisThread: number + maxBatchSize: number + cacheSizePowerOfTwo: number + command: string + outputTail: string + updatedAt: string +} + +export interface KataGoAssetInstallRequest { + presetId?: KataGoModelPresetId +} + +export type KataGoAssetInstallStage = 'discovering' | 'downloading-model' | 'copying-binary' | 'writing-manifest' | 'done' | 'error' + +export interface KataGoAssetInstallProgress { + stage: KataGoAssetInstallStage + message: string + receivedBytes?: number + totalBytes?: number + percent?: number +} + +export interface KataGoAssetInstallResult { + ok: boolean + presetId: KataGoModelPresetId + modelPath: string + binaryPath: string + downloadedModel: boolean + copiedBinary: boolean + detail: string +} + +export type ReleaseReadinessStatus = 'pass' | 'warn' | 'fail' | 'unknown' + +export interface ReleaseReadinessItem { + id: string + label: string + status: ReleaseReadinessStatus + detail?: string +} + +export interface ReleaseReadinessFlags { + automationReady: boolean + assetsReady: boolean + installersReady: boolean + signingReady: boolean + windowsSmokeReady: boolean + visualQaReady: boolean + publicBetaReady: boolean +} + +export interface ReleaseReadinessResult { + status: ReleaseReadinessStatus + items: ReleaseReadinessItem[] + flags: ReleaseReadinessFlags +} + export interface ReviewRequest { gameId: string playerName: string @@ -133,6 +247,43 @@ export interface KnowledgePacket { score: number } +export type KnowledgeMatchType = 'joseki' | 'life_death' | 'tesuji' | 'shape' | 'concept' +export type KnowledgeMatchConfidence = 'exact' | 'strong' | 'partial' | 'weak' +export type KnowledgeSourceKind = 'original' | 'common-pattern' | 'licensed-source' + +export interface RecommendedProblem { + id: string + title: string + problemType: 'life_death' | 'tesuji' + difficulty: string + objective: string + firstHint: string + answerSummary: string + tags: string[] +} + +export interface KnowledgeMatch { + id: string + matchType: KnowledgeMatchType + title: string + confidence: KnowledgeMatchConfidence + score: number + reason: string[] + applicability: string + teachingPayload: { + summary: string + recognition: string + correctIdea: string + keyVariations: string[] + memoryCue: string + commonMistakes: string[] + drills: string[] + boundary: string + sourceKind: KnowledgeSourceKind + } + relatedProblems: RecommendedProblem[] +} + export interface KataGoCandidate { move: string winrate: number @@ -162,6 +313,11 @@ export interface KataGoMoveAnalysis { move: string winrate: number scoreLead: number + playerWinrate?: number + playerScoreLead?: number + visits?: number + rank?: number + source?: 'candidate' | 'forced' | 'after-root' winrateLoss: number scoreLoss: number } @@ -170,11 +326,23 @@ export interface KataGoMoveAnalysis { export interface StudentProfile { id: string + studentId: string name: string + displayName: string + primaryFoxNickname?: string + aliases: string[] + createdFrom: 'fox' | 'sgf' | 'manual' | 'legacy' userLevel: CoachUserLevel gamesReviewed: number + weaknessStats: Record + recentPatterns: string[] + trainingFocus: string[] + recentGameIds: string[] commonMistakes: Array<{ tag: string; count: number }> trainingThemes: string[] + josekiWeaknesses?: string[] + lifeDeathWeaknesses?: string[] + tesujiWeaknesses?: string[] typicalMoves: Array<{ gameId: string moveNumber: number @@ -183,9 +351,49 @@ export interface StudentProfile { lossScore: number }> updatedAt: string + createdAt: string + lastAnalyzedAt?: string +} + +export interface StudentBindingSuggestion { + student: StudentProfile + confidence: 'high' | 'medium' | 'low' + reason: string + color?: StoneColor +} + +export interface TeacherKeyMistake { + moveNumber?: number + color?: StoneColor + played?: string + recommended?: string + errorType: string + severity: 'inaccuracy' | 'mistake' | 'blunder' + evidence: string + explanation: string +} + +export interface StructuredTeacherResult { + taskType: 'current-move' | 'full-game' | 'recent-games' | 'freeform' + headline: string + summary: string + keyMistakes: TeacherKeyMistake[] + correctThinking: string[] + drills: string[] + followupQuestions: string[] + markdown: string + knowledgeCardIds: string[] + knowledgeMatches?: KnowledgeMatch[] + recommendedProblems?: RecommendedProblem[] + profileUpdates: { + errorTypes: string[] + patterns: string[] + trainingFocus: string[] + } } export interface TeacherRunRequest { + runId?: string mode?: TeacherRunMode prompt: string gameId?: string @@ -195,15 +403,40 @@ export interface TeacherRunRequest { prefetchedAnalysis?: KataGoMoveAnalysis } +export type TeacherRunProgressStage = 'queued' | 'tool' | 'assistant-start' | 'assistant-delta' | 'done' | 'error' + +export interface TeacherRunProgress { + runId: string + stage: TeacherRunProgressStage + message?: string + markdownDelta?: string + markdown?: string + toolLogs?: TeacherToolLog[] + result?: TeacherRunResult + error?: string +} + export interface AnalyzePositionRequest { gameId: string moveNumber: number maxVisits?: number + runId?: string + reportDuringSearchEvery?: number +} + +export interface AnalyzePositionProgress { + runId?: string + gameId: string + moveNumber: number + analysis: KataGoMoveAnalysis + isFinal: boolean } export interface AnalyzeGameQuickRequest { gameId: string maxVisits?: number + refineVisits?: number + refineTopN?: number runId?: string } @@ -223,7 +456,11 @@ export interface TeacherRunResult { toolLogs: TeacherToolLog[] analysis?: KataGoMoveAnalysis knowledge: KnowledgePacket[] + knowledgeMatches?: KnowledgeMatch[] + recommendedProblems?: RecommendedProblem[] studentProfile?: StudentProfile + structured?: StructuredTeacherResult + structuredResult?: StructuredTeacherResult reportPath?: string } @@ -238,6 +475,17 @@ export interface LlmSettingsTestResult { message: string } +export interface LlmModelsListRequest { + llmBaseUrl: string + llmApiKey: string +} + +export interface LlmModelsListResult { + ok: boolean + models: string[] + message: string +} + export interface DashboardData { settings: AppSettings games: LibraryGame[] diff --git a/src/main/services/diagnostics/index.ts b/src/main/services/diagnostics/index.ts new file mode 100644 index 0000000..9609825 --- /dev/null +++ b/src/main/services/diagnostics/index.ts @@ -0,0 +1,194 @@ +import { constants } from 'node:fs' +import { access, mkdir, unlink, writeFile } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { appHome, getSettings, hasLlmApiKey } from '@main/lib/store' +import { resolveKataGoRuntime } from '../katagoRuntime' +import { probeOpenAICompatibleProvider } from '../llm/openaiCompatibleProvider' +import { inspectKataGoAssets } from '../katago/katagoAssets' +import type { DiagnosticCheck, DiagnosticsReport, DiagnosticsOverall } from './types' + +function isReleaseRuntime(): boolean { + return !process.env.ELECTRON_RENDERER_URL +} + +async function checkWritableHome(): Promise { + try { + await mkdir(appHome, { recursive: true }) + const probePath = join(appHome, '.gomentor-write-test') + await writeFile(probePath, 'ok', 'utf8') + await unlink(probePath) + return { + id: 'app-home-writable', + title: '用户数据目录', + status: 'pass', + required: true, + detail: `可写: ${appHome}` + } + } catch (error) { + return { + id: 'app-home-writable', + title: '用户数据目录', + status: 'fail', + required: true, + detail: '应用无法写入用户数据目录,棋谱、画像和报告无法保存。', + action: '请检查目录权限,或把应用安装到有权限的位置。', + technicalDetail: String(error) + } + } +} + +async function checkKatagoBinary(): Promise { + const runtime = resolveKataGoRuntime(getSettings()) + const required = isReleaseRuntime() + if (!runtime.katagoBin) { + return { + id: 'katago-binary', + title: 'KataGo 引擎', + status: required ? 'fail' : 'warn', + required, + detail: '未找到内置或本机 KataGo 引擎。', + action: required + ? '请确认安装包包含 data/katago/bin/-/katago。' + : '开发环境可稍后运行 scripts/prepare_katago_assets.mjs 或使用系统 KataGo。' + } + } + try { + await access(runtime.katagoBin, constants.X_OK) + } catch (error) { + // Windows does not use POSIX executable bits; existence is enough there. + if (process.platform !== 'win32') { + return { + id: 'katago-binary', + title: 'KataGo 引擎', + status: required ? 'fail' : 'warn', + required, + detail: `找到 ${basename(runtime.katagoBin)},但没有执行权限。`, + action: 'macOS/Linux 下请确保内置 katago 文件有可执行权限。', + technicalDetail: String(error) + } + } + } + return { + id: 'katago-binary', + title: 'KataGo 引擎', + status: 'pass', + required, + detail: `已找到: ${basename(runtime.katagoBin)}` + } +} + +async function checkKatagoModel(): Promise { + const runtime = resolveKataGoRuntime(getSettings()) + const required = isReleaseRuntime() + if (!runtime.katagoModel) { + return { + id: 'katago-model', + title: 'KataGo 默认模型', + status: required ? 'fail' : 'warn', + required, + detail: '未找到默认 KataGo 模型。', + action: required + ? 'P0 安装包应该内置 b18 默认模型;请确认 data/katago/models 中存在默认模型文件。' + : '开发环境可先保留 manifest,通过资源准备脚本或 CI release artifact 注入模型。' + } + } + return { + id: 'katago-model', + title: 'KataGo 默认模型', + status: 'pass', + required, + detail: `已找到: ${basename(runtime.katagoModel)}` + } +} + +async function checkBundledKataGoAssets(): Promise { + const status = await inspectKataGoAssets() + const required = isReleaseRuntime() + if (status.ready) { + return { + id: 'katago-assets', + title: '内置 KataGo 资源', + status: 'pass', + required, + detail: status.detail + } + } + return { + id: 'katago-assets', + title: '内置 KataGo 资源', + status: required ? 'fail' : 'warn', + required, + detail: status.detail, + action: required + ? '请重新安装完整安装包,或检查 data/katago 资源是否损坏。' + : '开发环境可通过 scripts/prepare_katago_assets.mjs 准备资源。' + } +} + +async function checkLlmProxy(): Promise { + const settings = getSettings() + const configured = Boolean(settings.llmBaseUrl.trim() && (settings.llmApiKey.trim() || hasLlmApiKey()) && settings.llmModel.trim()) + if (!configured) { + return { + id: 'llm-proxy', + title: 'Claude 兼容代理', + status: 'warn', + required: false, + detail: '还没有配置 Claude 兼容代理。KataGo 基础分析可用,但老师讲解不可用。', + action: '在设置中填写 Base URL、API Key 和 Claude 模型名。' + } + } + const result = await probeOpenAICompatibleProvider({ + llmBaseUrl: settings.llmBaseUrl, + llmApiKey: settings.llmApiKey, + llmModel: settings.llmModel + }) + return { + id: 'llm-proxy', + title: 'Claude 兼容代理', + status: result.ok ? 'pass' : 'warn', + required: false, + detail: result.message, + action: result.ok ? undefined : '请检查代理是否启动、API Key 是否正确、模型是否支持图片输入。', + technicalDetail: result.technicalDetail + } +} + +function summarize(checks: DiagnosticCheck[]): Pick { + const failedRequired = checks.filter((check) => check.required && check.status === 'fail') + if (failedRequired.length > 0) { + return { + overall: 'blocked', + summary: `有 ${failedRequired.length} 个必需项未通过,暂时无法进行完整复盘。` + } + } + const warnings = checks.filter((check) => check.status === 'warn' || check.status === 'fail') + if (warnings.length > 0) { + return { + overall: 'fixable', + summary: `基础功能可用,但还有 ${warnings.length} 项建议处理。` + } + } + return { + overall: 'ready', + summary: 'GoMentor 已准备好开始复盘。' + } +} + +export async function collectDiagnostics(): Promise { + const checks = await Promise.all([ + checkWritableHome(), + checkBundledKataGoAssets(), + checkKatagoBinary(), + checkKatagoModel(), + checkLlmProxy() + ]) + const result = summarize(checks) + return { + ...result, + generatedAt: new Date().toISOString(), + checks + } +} + +export type { DiagnosticCheck, DiagnosticsOverall, DiagnosticsReport } diff --git a/src/main/services/diagnostics/types.ts b/src/main/services/diagnostics/types.ts new file mode 100644 index 0000000..3cfc5f7 --- /dev/null +++ b/src/main/services/diagnostics/types.ts @@ -0,0 +1,19 @@ +export type DiagnosticStatus = 'pass' | 'warn' | 'fail' +export type DiagnosticsOverall = 'ready' | 'fixable' | 'blocked' + +export interface DiagnosticCheck { + id: string + title: string + status: DiagnosticStatus + required: boolean + detail: string + action?: string + technicalDetail?: string +} + +export interface DiagnosticsReport { + overall: DiagnosticsOverall + summary: string + generatedAt: string + checks: DiagnosticCheck[] +} diff --git a/src/main/services/fox.ts b/src/main/services/fox.ts index 3777b07..0906165 100644 --- a/src/main/services/fox.ts +++ b/src/main/services/fox.ts @@ -1,5 +1,7 @@ -import type { FoxSyncRequest, FoxSyncResult } from '@main/lib/types' -import { saveFoxSgf } from './sgf' +import { existsSync } from 'node:fs' +import { upsertGames } from '@main/lib/store' +import type { FoxSyncRequest, FoxSyncResult, LibraryGame } from '@main/lib/types' +import { foxSgfPathForGameId, saveFoxSgf } from './sgf' const BASE_URL = 'https://h5.foxwq.com/yehuDiamond/chessbook_local' const QUERY_USER_URL = 'https://newframe.foxwq.com/cgi/QueryUserInfoPanel' @@ -20,16 +22,30 @@ interface FoxUserResponse { interface FoxListItem { chessid?: string | number + starttime?: string blacknick?: string + blacknickname?: string whitenick?: string + whitenickname?: string blackname?: string whitename?: string + blackenname?: string + whiteenname?: string + blackuid?: string | number + whiteuid?: string | number + movenum?: string | number + winner?: string | number + point?: string | number + rule?: string | number title?: string dt?: string result?: string } interface FoxListResponse { + result?: number + resultstr?: string + errmsg?: string data?: FoxListItem[] chesslist?: FoxListItem[] } @@ -82,6 +98,9 @@ async function fetchList(uid: string): Promise { url.searchParams.set('searchkey', '') url.searchParams.set('uin', uid) const json = await getJson(url.toString()) + if (typeof json.result === 'number' && json.result !== 0) { + throw new Error(first(json.resultstr, json.errmsg) || `野狐棋谱列表返回错误:${json.result}`) + } return json.data ?? json.chesslist ?? [] } @@ -96,25 +115,120 @@ async function fetchSgf(chessId: string): Promise { return sgf } +function foxGameId(uid: string, chessId: string): string { + return `fox:${uid}:${chessId}` +} + +function numberValue(value: unknown): number | undefined { + const parsed = typeof value === 'number' ? value : Number.parseFloat(String(value ?? '')) + return Number.isFinite(parsed) ? parsed : undefined +} + +function foxName(item: FoxListItem, ...keys: Array): string { + return first(...keys.map((key) => { + const value = item[key] + return value === undefined || value === null ? undefined : String(value) + })) +} + +function normalizeFoxDate(item: FoxListItem, fallbackIndex: number): { display: string; createdAt: string } { + const raw = first(item.starttime, item.dt) + const normalized = raw.replace(/\//g, '-').trim() + const date = normalized.slice(0, 10) + const parsed = Date.parse(normalized.includes('T') ? normalized : normalized.replace(' ', 'T')) + return { + display: date || normalized, + createdAt: Number.isFinite(parsed) + ? new Date(parsed).toISOString() + : new Date(Date.now() - fallbackIndex * 1000).toISOString() + } +} + +function formatFoxPoint(point: number | undefined): string { + if (typeof point !== 'number' || !Number.isFinite(point)) { + return '' + } + if (point < 0) { + if (point === -1) return '+R' + if (point === -2) return '+T' + return '+' + } + return `+${(point / 100).toFixed(point % 100 === 0 ? 0 : 2).replace(/\.?0+$/, '')}` +} + +function foxResult(item: FoxListItem): string { + const explicit = first(item.result) + if (explicit) { + return explicit + } + const winner = numberValue(item.winner) + if (winner === 1) return `B${formatFoxPoint(numberValue(item.point))}` + if (winner === 2) return `W${formatFoxPoint(numberValue(item.point))}` + return '' +} + +function indexedFoxGame(item: FoxListItem, user: { uid: string; nickname: string }, index: number): LibraryGame | null { + const chessId = String(item.chessid ?? '').trim() + if (!chessId) { + return null + } + const black = foxName(item, 'blacknick', 'blacknickname', 'blackname', 'blackenname') + const white = foxName(item, 'whitenick', 'whitenickname', 'whitename', 'whiteenname') + const title = first(item.title, [black, white].filter(Boolean).join(' vs '), chessId) + const id = foxGameId(user.uid, chessId) + const filePath = foxSgfPathForGameId(id) + const date = normalizeFoxDate(item, index) + const loaded = existsSync(filePath) + return { + id, + title, + event: first(item.title), + black, + white, + result: foxResult(item), + date: date.display, + source: 'fox', + sourceLabel: `Fox ${user.nickname} / ${user.uid}`, + filePath, + createdAt: date.createdAt, + downloadStatus: loaded ? 'downloaded' : 'remote', + remoteId: chessId, + remoteUid: user.uid, + moveCount: numberValue(item.movenum) + } +} + export async function syncFoxGames(request: FoxSyncRequest): Promise { const user = await resolveUser(request.keyword.trim()) const list = await fetchList(user.uid) - const saved = [] const selectedItems = typeof request.maxGames === 'number' ? list.slice(0, request.maxGames) : list - for (const item of selectedItems) { - const chessId = String(item.chessid ?? '').trim() - if (!chessId) continue - try { - const sgf = await fetchSgf(chessId) - const title = first(item.title, `${first(item.blacknick, item.blackname)} vs ${first(item.whitenick, item.whitename)}`, chessId) - saved.push(saveFoxSgf(sgf, title, `Fox ${user.nickname} / ${user.uid}`)) - } catch (error) { - console.warn('fox sync skipped', chessId, error) - } - } + const saved = selectedItems + .map((item, index) => indexedFoxGame(item, user, index)) + .filter((game): game is LibraryGame => Boolean(game)) return { nickname: user.nickname, uid: user.uid, saved } } + +export async function ensureFoxGameDownloaded(game: LibraryGame): Promise { + if (game.source !== 'fox' || game.downloadStatus === 'downloaded' || existsSync(game.filePath)) { + const readyGame = { + ...game, + downloadStatus: game.source === 'fox' ? 'downloaded' : game.downloadStatus + } + if (game.source === 'fox' && game.downloadStatus !== 'downloaded') { + upsertGames([readyGame]) + } + return readyGame + } + const chessId = game.remoteId || game.id.split(':').pop() || '' + if (!chessId) { + throw new Error(`棋谱 ${game.title || game.id} 缺少野狐 chessid,无法下载。`) + } + const sgf = await fetchSgf(chessId) + const readyGame = saveFoxSgf(sgf, game.title || chessId, game.sourceLabel, game) + upsertGames([readyGame]) + return readyGame +} diff --git a/src/main/services/katago.ts b/src/main/services/katago.ts index 1a25944..94c727e 100644 --- a/src/main/services/katago.ts +++ b/src/main/services/katago.ts @@ -1,21 +1,24 @@ import { spawn } from 'node:child_process' -import { getSettings } from '@main/lib/store' +import { findGame, getSettings } from '@main/lib/store' import type { GameMove, KataGoCandidate, KataGoMoveAnalysis } from '@main/lib/types' -import { findGame } from '@main/lib/store' import { readGameRecord } from './sgf' import { resolveKataGoRuntime } from './katagoRuntime' +import { ensureFoxGameDownloaded } from './fox' interface KataGoResponse { id?: string error?: string + isDuringSearch?: boolean rootInfo?: { winrate?: number scoreLead?: number + scoreMean?: number } moveInfos?: Array<{ move?: string winrate?: number scoreLead?: number + scoreMean?: number visits?: number order?: number prior?: number @@ -29,6 +32,13 @@ interface AnalysisQuery { boardSize: number komi: number maxVisits: number + reportDuringSearchEvery?: number + overrideSettings?: Record + allowMoves?: Array<{ + player: GameMove['color'] + moves: string[] + untilDepth: number + }> } interface QuickProgress { @@ -37,6 +47,12 @@ interface QuickProgress { totalPositions: number } +const QUICK_ANALYSIS_FAST_VISITS = 25 +const QUICK_ANALYSIS_REFINE_VISITS = 120 +const QUICK_ANALYSIS_REFINE_TOP_N = 18 +const QUICK_ANALYSIS_REFINE_MIN_LOSS = 4 +const QUICK_ANALYSIS_WIDE_ROOT_NOISE = 0.04 + function moveHistory(moves: GameMove[]): Array<[string, string]> { return moves.filter((move) => !move.pass).map((move) => [move.color, move.gtp]) } @@ -55,15 +71,15 @@ function root(response: KataGoResponse): { winrate: number; scoreLead: number } } return { winrate: Number(response.rootInfo.winrate ?? 0.5) * 100, - scoreLead: Number(response.rootInfo.scoreLead ?? 0) + scoreLead: Number(response.rootInfo.scoreLead ?? response.rootInfo.scoreMean ?? 0) } } function candidates(response: KataGoResponse): KataGoCandidate[] { - return (response.moveInfos ?? []).slice(0, 8).map((move, index) => ({ + return (response.moveInfos ?? []).map((move, index) => ({ move: move.move ?? '', winrate: Number(move.winrate ?? 0.5) * 100, - scoreLead: Number(move.scoreLead ?? 0), + scoreLead: Number(move.scoreLead ?? move.scoreMean ?? 0), visits: Number(move.visits ?? 0), order: Number(move.order ?? index), prior: Number(move.prior ?? 0) * 100, @@ -71,31 +87,130 @@ function candidates(response: KataGoResponse): KataGoCandidate[] { })) } -function judgement(winrateLoss: number, scoreLoss: number): KataGoMoveAnalysis['judgement'] { - if (winrateLoss >= 15 || scoreLoss >= 8) { +function mergePlayedCandidateIntoTopMoves( + topMoves: KataGoCandidate[], + currentMove?: GameMove, + forcedCandidate?: KataGoCandidate +): KataGoCandidate[] { + if (!currentMove || currentMove.pass || !forcedCandidate) { + return topMoves + } + const playedKey = moveKey(currentMove.gtp) + if (!playedKey || topMoves.some((candidate) => moveKey(candidate.move) === playedKey)) { + return topMoves + } + return [...topMoves, forcedCandidate] +} + +function displayCandidates(response: KataGoResponse, currentMove?: GameMove, forcedCandidate?: KataGoCandidate): KataGoCandidate[] { + return mergePlayedCandidateIntoTopMoves(candidates(response).slice(0, 8), currentMove, forcedCandidate) +} + +function judgement(winrateLoss: number, _scoreLoss: number): KataGoMoveAnalysis['judgement'] { + if (winrateLoss >= 15) { return 'blunder' } - if (winrateLoss >= 7 || scoreLoss >= 3.5) { + if (winrateLoss >= 7) { return 'mistake' } - if (winrateLoss >= 2.5 || scoreLoss >= 1.2) { + if (winrateLoss >= 2.5) { return 'inaccuracy' } return 'good_move' } +function moveKey(move: string | undefined): string { + return (move ?? '').trim().toUpperCase() +} + +function playerWinrate(blackWinrate: number, color: GameMove['color']): number { + return color === 'B' ? blackWinrate : 100 - blackWinrate +} + +function playerScoreLead(scoreLead: number, color: GameMove['color']): number { + return color === 'B' ? scoreLead : -scoreLead +} + +function findPlayedCandidate( + currentMove: GameMove | undefined, + topMoves: KataGoCandidate[] +): { candidate?: KataGoCandidate; rank?: number } { + if (!currentMove) { + return {} + } + const index = topMoves.findIndex((candidate) => moveKey(candidate.move) === moveKey(currentMove.gtp)) + return index >= 0 ? { candidate: topMoves[index], rank: index + 1 } : {} +} + +function playedMoveValue( + currentMove: GameMove | undefined, + topMoves: KataGoCandidate[], + afterRoot: { winrate: number; scoreLead: number }, + forcedCandidate?: KataGoCandidate +): { winrate: number; scoreLead: number; playerWinrate?: number; playerScoreLead?: number; visits?: number; rank?: number; source: 'candidate' | 'forced' | 'after-root' } { + const { candidate, rank } = findPlayedCandidate(currentMove, topMoves) + const actual = candidate ?? forcedCandidate + const winrate = actual?.winrate ?? afterRoot.winrate + const scoreLead = actual?.scoreLead ?? afterRoot.scoreLead + return { + winrate, + scoreLead, + playerWinrate: currentMove ? playerWinrate(winrate, currentMove.color) : undefined, + playerScoreLead: currentMove ? playerScoreLead(scoreLead, currentMove.color) : undefined, + visits: actual?.visits, + rank, + source: candidate ? 'candidate' : forcedCandidate ? 'forced' : 'after-root' + } +} + +function forcePlayedMoveQuery( + id: string, + moves: GameMove[], + currentMove: GameMove | undefined, + boardSize: number, + komi: number, + maxVisits: number, + reportDuringSearchEvery?: number, + overrideSettings?: AnalysisQuery['overrideSettings'] +): AnalysisQuery | undefined { + if (!currentMove || currentMove.pass || !moveKey(currentMove.gtp)) { + return undefined + } + return { + id, + moves: moveHistory(moves), + boardSize, + komi, + maxVisits, + reportDuringSearchEvery, + overrideSettings, + allowMoves: [{ + player: currentMove.color, + moves: [currentMove.gtp], + untilDepth: 1 + }] + } +} + +function forcedPlayedCandidate(response: KataGoResponse | undefined, currentMove: GameMove | undefined): KataGoCandidate | undefined { + if (!response || !currentMove) { + return undefined + } + const playedKey = moveKey(currentMove.gtp) + return candidates(response).find((candidate) => moveKey(candidate.move) === playedKey) ?? candidates(response)[0] +} + function playedLoss( currentMove: GameMove | undefined, best: KataGoCandidate | undefined, - afterRoot: { winrate: number; scoreLead: number } + actual: { winrate: number; scoreLead: number } ): { winrateLoss: number; scoreLoss: number } { if (!currentMove || !best) { return { winrateLoss: 0, scoreLoss: 0 } } - const sign = currentMove.color === 'B' ? 1 : -1 return { - winrateLoss: Math.max(0, (best.winrate - afterRoot.winrate) * sign), - scoreLoss: Math.max(0, (best.scoreLead - afterRoot.scoreLead) * sign) + winrateLoss: Math.max(0, playerWinrate(best.winrate, currentMove.color) - playerWinrate(actual.winrate, currentMove.color)), + scoreLoss: Math.max(0, playerScoreLead(best.scoreLead, currentMove.color) - playerScoreLead(actual.scoreLead, currentMove.color)) } } @@ -156,9 +271,11 @@ async function queryKataGoBatch( const parsed = JSON.parse(line) as KataGoResponse const id = parsed.id ?? '' if (id) { - results.set(id, parsed) onResponse?.(parsed) } + if (id && !parsed.isDuringSearch) { + results.set(id, parsed) + } } catch (error) { settled = true clearTimeout(timer) @@ -199,7 +316,7 @@ async function queryKataGoBatch( }) for (const query of queries) { - child.stdin.write(`${JSON.stringify({ + const payload: Record = { id: query.id, moves: query.moves, initialStones: [], @@ -208,7 +325,17 @@ async function queryKataGoBatch( boardXSize: query.boardSize, boardYSize: query.boardSize, maxVisits: query.maxVisits - })}\n`) + } + if (query.reportDuringSearchEvery !== undefined) { + payload.reportDuringSearchEvery = query.reportDuringSearchEvery + } + if (query.overrideSettings) { + payload.overrideSettings = query.overrideSettings + } + if (query.allowMoves) { + payload.allowMoves = query.allowMoves + } + child.stdin.write(`${JSON.stringify(payload)}\n`) } child.stdin.end() }) @@ -234,42 +361,73 @@ export async function analyzePosition( moveNumber: number, maxVisits = 500 ): Promise { - const game = findGame(gameId) - if (!game) { + const indexedGame = findGame(gameId) + if (!indexedGame) { throw new Error(`找不到棋谱: ${gameId}`) } + const game = await ensureFoxGameDownloaded(indexedGame) const record = readGameRecord(game) 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 komi = normalizeKomi(record.komi) - const beforeResponse = await queryKataGo( - moveHistory(beforeMoves), - record.boardSize, - komi, - `${gameId}-before-${moveNumber}`, - maxVisits - ) - const afterResponse = await queryKataGo( - moveHistory(afterMoves), - record.boardSize, - komi, - `${gameId}-after-${moveNumber}`, - Math.max(120, Math.floor(maxVisits * 0.6)) - ) + const afterVisits = Math.max(24, Math.floor(maxVisits * 0.55)) + const beforeId = `${gameId}-before-${moveNumber}` + const afterId = `${gameId}-after-${moveNumber}` + const actualId = `${gameId}-actual-${moveNumber}` + const queries: AnalysisQuery[] = [ + { + id: beforeId, + moves: moveHistory(beforeMoves), + boardSize: record.boardSize, + komi, + maxVisits + }, + { + 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 beforeResponse = responses.get(beforeId) + const afterResponse = responses.get(afterId) + if (!beforeResponse || !afterResponse) { + throw new Error(`KataGo 没有返回完整局面分析: before=${Boolean(beforeResponse)} after=${Boolean(afterResponse)}`) + } + return buildMoveAnalysis(gameId, moveNumber, record.boardSize, currentMove, beforeResponse, afterResponse, responses.get(actualId)) +} +function buildMoveAnalysis( + gameId: string, + moveNumber: number, + boardSize: number, + currentMove: GameMove | undefined, + beforeResponse: KataGoResponse, + afterResponse: KataGoResponse, + actualResponse?: KataGoResponse +): KataGoMoveAnalysis { const beforeRoot = root(beforeResponse) const afterRoot = root(afterResponse) - const topMoves = candidates(beforeResponse) - const afterTopMoves = candidates(afterResponse) - const best = topMoves[0] - const { winrateLoss, scoreLoss } = playedLoss(currentMove, best, afterRoot) + const searchMoves = candidates(beforeResponse) + const forcedActual = forcedPlayedCandidate(actualResponse, currentMove) + const topMoves = displayCandidates(beforeResponse, currentMove, forcedActual) + const afterTopMoves = candidates(afterResponse).slice(0, 8) + const best = searchMoves[0] ?? topMoves[0] + const actual = playedMoveValue(currentMove, searchMoves, afterRoot, forcedActual) + const { winrateLoss, scoreLoss } = playedLoss(currentMove, best, actual) return { gameId, moveNumber, - boardSize: record.boardSize, + boardSize, currentMove, before: { ...beforeRoot, @@ -282,8 +440,13 @@ export async function analyzePosition( playedMove: currentMove ? { move: currentMove.gtp, - winrate: afterRoot.winrate, - scoreLead: afterRoot.scoreLead, + winrate: actual.winrate, + scoreLead: actual.scoreLead, + playerWinrate: actual.playerWinrate, + playerScoreLead: actual.playerScoreLead, + visits: actual.visits, + rank: actual.rank, + source: actual.source, winrateLoss, scoreLoss } @@ -292,20 +455,111 @@ export async function analyzePosition( } } +export async function analyzePositionWithProgress( + gameId: string, + moveNumber: number, + maxVisits = 500, + onProgress?: (analysis: KataGoMoveAnalysis, isFinal: boolean) => void, + reportDuringSearchEvery = 0.2 +): Promise { + const indexedGame = findGame(gameId) + if (!indexedGame) { + throw new Error(`找不到棋谱: ${gameId}`) + } + const game = await ensureFoxGameDownloaded(indexedGame) + const record = readGameRecord(game) + 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 komi = normalizeKomi(record.komi) + const afterVisits = Math.max(24, Math.floor(maxVisits * 0.55)) + const beforeId = `${gameId}-before-${moveNumber}-stream` + const afterId = `${gameId}-after-${moveNumber}-stream` + const actualId = `${gameId}-actual-${moveNumber}-stream` + let latestBefore: KataGoResponse | undefined + let latestAfter: KataGoResponse | undefined + let latestActual: KataGoResponse | undefined + + let responses: Map + try { + const queries: AnalysisQuery[] = [ + { + id: beforeId, + moves: moveHistory(beforeMoves), + boardSize: record.boardSize, + komi, + maxVisits, + reportDuringSearchEvery + }, + { + id: afterId, + moves: moveHistory(afterMoves), + boardSize: record.boardSize, + komi, + maxVisits: afterVisits, + reportDuringSearchEvery + } + ] + const actualQuery = forcePlayedMoveQuery(actualId, beforeMoves, currentMove, record.boardSize, komi, maxVisits, reportDuringSearchEvery) + if (actualQuery) { + queries.push(actualQuery) + } + responses = await queryKataGoBatch(queries, (response) => { + if (response.id === beforeId) { + latestBefore = response + } + if (response.id === afterId) { + latestAfter = response + } + if (response.id === actualId) { + latestActual = response + } + if (latestBefore?.rootInfo && latestAfter?.rootInfo && (!actualQuery || latestActual?.rootInfo)) { + const partial = buildMoveAnalysis(gameId, moveNumber, record.boardSize, currentMove, latestBefore, latestAfter, latestActual) + onProgress?.(partial, !latestBefore.isDuringSearch && !latestAfter.isDuringSearch && !latestActual?.isDuringSearch) + } + }) + } catch (error) { + if (String(error).includes('KataGo 分析超时') && latestBefore?.rootInfo && latestAfter?.rootInfo) { + const partial = buildMoveAnalysis(gameId, moveNumber, record.boardSize, currentMove, latestBefore, latestAfter, latestActual) + onProgress?.(partial, true) + return partial + } + throw error + } + + const beforeResponse = responses.get(beforeId) + const afterResponse = responses.get(afterId) + if (!beforeResponse || !afterResponse) { + throw new Error(`KataGo 没有返回完整局面分析: before=${Boolean(beforeResponse)} after=${Boolean(afterResponse)}`) + } + const final = buildMoveAnalysis(gameId, moveNumber, record.boardSize, currentMove, beforeResponse, afterResponse, responses.get(actualId)) + onProgress?.(final, true) + return final +} + export async function analyzeGameQuick( gameId: string, - maxVisits = 12, - onProgress?: (progress: QuickProgress) => void + maxVisits = QUICK_ANALYSIS_FAST_VISITS, + onProgress?: (progress: QuickProgress) => void, + options: { + refineVisits?: number + refineTopN?: number + } = {} ): Promise { - const game = findGame(gameId) - if (!game) { + const indexedGame = findGame(gameId) + if (!indexedGame) { throw new Error(`找不到棋谱: ${gameId}`) } + const game = await ensureFoxGameDownloaded(indexedGame) const record = readGameRecord(game) const normalizedKomi = normalizeKomi(record.komi) const moves = record.moves const queries: AnalysisQuery[] = [] + const quickVisits = Math.max(QUICK_ANALYSIS_FAST_VISITS, Math.round(maxVisits)) + const quickOverrideSettings = { wideRootNoise: QUICK_ANALYSIS_WIDE_ROOT_NOISE } + const rootPositionCount = moves.length + 1 for (let moveNumber = 0; moveNumber <= moves.length; moveNumber += 1) { queries.push({ @@ -313,14 +567,31 @@ export async function analyzeGameQuick( moves: moveHistory(moves.slice(0, moveNumber)), boardSize: record.boardSize, komi: normalizedKomi, - maxVisits + maxVisits: quickVisits, + overrideSettings: quickOverrideSettings }) + const currentMove = moves[moveNumber] + const actualQuery = forcePlayedMoveQuery( + `${gameId}-quick-actual-${moveNumber + 1}`, + moves.slice(0, moveNumber), + currentMove, + record.boardSize, + normalizedKomi, + quickVisits, + undefined, + quickOverrideSettings + ) + if (actualQuery) { + queries.push(actualQuery) + } } const roots = new Map() const topMovesByPosition = new Map() + const actualCandidatesByMove = new Map() const emitted = new Set() const idPrefix = `${gameId}-quick-` + const actualIdPrefix = `${gameId}-quick-actual-` function buildEvaluation(moveNumber: number): KataGoMoveAnalysis | null { const before = roots.get(moveNumber - 1) @@ -331,8 +602,15 @@ export async function analyzeGameQuick( const beforeTopMoves = topMovesByPosition.get(moveNumber - 1) ?? [] const afterTopMoves = topMovesByPosition.get(moveNumber) ?? [] const currentMove = moves[moveNumber - 1] - const winrateSwing = Math.abs(after.winrate - before.winrate) - const scoreSwing = Math.abs(after.scoreLead - before.scoreLead) + const forcedActual = actualCandidatesByMove.get(moveNumber) + const playedCandidate = findPlayedCandidate(currentMove, beforeTopMoves).candidate + if (currentMove && !currentMove.pass && !playedCandidate && !forcedActual) { + return null + } + const displayBeforeMoves = mergePlayedCandidateIntoTopMoves(beforeTopMoves, currentMove, forcedActual) + const best = beforeTopMoves[0] ?? displayBeforeMoves[0] + const actual = playedMoveValue(currentMove, beforeTopMoves, after, forcedActual) + const { winrateLoss, scoreLoss } = playedLoss(currentMove, best, actual) return { gameId, moveNumber, @@ -340,7 +618,7 @@ export async function analyzeGameQuick( currentMove, before: { ...before, - topMoves: beforeTopMoves + topMoves: displayBeforeMoves }, after: { ...after, @@ -348,12 +626,17 @@ export async function analyzeGameQuick( }, playedMove: { move: currentMove.gtp, - winrate: after.winrate, - scoreLead: after.scoreLead, - winrateLoss: winrateSwing, - scoreLoss: scoreSwing + winrate: actual.winrate, + scoreLead: actual.scoreLead, + playerWinrate: actual.playerWinrate, + playerScoreLead: actual.playerScoreLead, + visits: actual.visits, + rank: actual.rank, + source: actual.source, + winrateLoss, + scoreLoss }, - judgement: judgement(winrateSwing, scoreSwing) + judgement: judgement(winrateLoss, scoreLoss) } } @@ -368,12 +651,23 @@ export async function analyzeGameQuick( emitted.add(moveNumber) onProgress({ evaluation, - analyzedPositions: roots.size, - totalPositions: queries.length + analyzedPositions: Math.min(roots.size, rootPositionCount), + totalPositions: rootPositionCount }) } const responses = await queryKataGoBatch(queries, (response) => { + if (response.id?.startsWith(actualIdPrefix)) { + const moveNumber = Number.parseInt(response.id.slice(actualIdPrefix.length), 10) + if (Number.isFinite(moveNumber)) { + const candidate = forcedPlayedCandidate(response, moves[moveNumber - 1]) + if (candidate) { + actualCandidatesByMove.set(moveNumber, candidate) + } + emitIfReady(moveNumber) + } + return + } if (!response.id?.startsWith(idPrefix)) { return } @@ -383,7 +677,7 @@ export async function analyzeGameQuick( } try { roots.set(position, root(response)) - topMovesByPosition.set(position, candidates(response)) + topMovesByPosition.set(position, candidates(response).slice(0, 8)) emitIfReady(position) emitIfReady(position + 1) } catch { @@ -394,7 +688,7 @@ export async function analyzeGameQuick( for (let moveNumber = 0; moveNumber <= moves.length; moveNumber += 1) { const response = responses.get(`${gameId}-quick-${moveNumber}`) if (response && !topMovesByPosition.has(moveNumber)) { - topMovesByPosition.set(moveNumber, candidates(response)) + topMovesByPosition.set(moveNumber, candidates(response).slice(0, 8)) } if (response && !roots.has(moveNumber)) { try { @@ -405,6 +699,17 @@ export async function analyzeGameQuick( } } + for (let moveNumber = 1; moveNumber <= moves.length; moveNumber += 1) { + if (actualCandidatesByMove.has(moveNumber)) { + continue + } + const response = responses.get(`${gameId}-quick-actual-${moveNumber}`) + const candidate = forcedPlayedCandidate(response, moves[moveNumber - 1]) + if (candidate) { + actualCandidatesByMove.set(moveNumber, candidate) + } + } + if (roots.size < 2) { throw new Error('KataGo 快速分析没有返回有效局面') } @@ -418,5 +723,85 @@ export async function analyzeGameQuick( evaluations.push(evaluation) } - return evaluations + const refineVisits = Math.max(quickVisits, Math.round(options.refineVisits ?? QUICK_ANALYSIS_REFINE_VISITS)) + const refineTopN = Math.max(0, Math.round(options.refineTopN ?? QUICK_ANALYSIS_REFINE_TOP_N)) + const refineMoveNumbers = refineVisits > quickVisits && refineTopN > 0 + ? evaluations + .filter((item) => (item.playedMove?.winrateLoss ?? 0) >= QUICK_ANALYSIS_REFINE_MIN_LOSS) + .sort((left, right) => + (right.playedMove?.winrateLoss ?? 0) - (left.playedMove?.winrateLoss ?? 0) || + left.moveNumber - right.moveNumber + ) + .slice(0, refineTopN) + .map((item) => item.moveNumber) + : [] + + if (refineMoveNumbers.length === 0) { + return evaluations + } + + const refineQueries: AnalysisQuery[] = [] + for (const moveNumber of refineMoveNumbers) { + const currentMove = moves[moveNumber - 1] + const beforeMoves = moves.slice(0, moveNumber - 1) + const afterMoves = moves.slice(0, moveNumber) + refineQueries.push({ + id: `${gameId}-quick-refine-before-${moveNumber}`, + moves: moveHistory(beforeMoves), + boardSize: record.boardSize, + komi: normalizedKomi, + maxVisits: refineVisits, + overrideSettings: quickOverrideSettings + }) + refineQueries.push({ + id: `${gameId}-quick-refine-after-${moveNumber}`, + moves: moveHistory(afterMoves), + boardSize: record.boardSize, + komi: normalizedKomi, + maxVisits: Math.max(quickVisits, Math.floor(refineVisits * 0.6)), + overrideSettings: quickOverrideSettings + }) + const actualQuery = forcePlayedMoveQuery( + `${gameId}-quick-refine-actual-${moveNumber}`, + beforeMoves, + currentMove, + record.boardSize, + normalizedKomi, + refineVisits, + undefined, + quickOverrideSettings + ) + if (actualQuery) { + refineQueries.push(actualQuery) + } + } + + const refinedResponses = await queryKataGoBatch(refineQueries) + const byMove = new Map(evaluations.map((item) => [item.moveNumber, item])) + let refinedCount = 0 + for (const moveNumber of refineMoveNumbers) { + const beforeResponse = refinedResponses.get(`${gameId}-quick-refine-before-${moveNumber}`) + const afterResponse = refinedResponses.get(`${gameId}-quick-refine-after-${moveNumber}`) + if (!beforeResponse || !afterResponse) { + continue + } + const refined = buildMoveAnalysis( + gameId, + moveNumber, + record.boardSize, + moves[moveNumber - 1], + beforeResponse, + afterResponse, + refinedResponses.get(`${gameId}-quick-refine-actual-${moveNumber}`) + ) + byMove.set(moveNumber, refined) + refinedCount += 1 + onProgress?.({ + evaluation: refined, + analyzedPositions: rootPositionCount + refinedCount, + totalPositions: rootPositionCount + refineMoveNumbers.length + }) + } + + return [...byMove.values()].sort((left, right) => left.moveNumber - right.moveNumber) } diff --git a/src/main/services/katago/katagoAssets.ts b/src/main/services/katago/katagoAssets.ts new file mode 100644 index 0000000..138cf38 --- /dev/null +++ b/src/main/services/katago/katagoAssets.ts @@ -0,0 +1,347 @@ +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { access, chmod, copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' +import { createWriteStream } from 'node:fs' +import { Transform, Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { basename, dirname, join } from 'node:path' +import { app } from 'electron' +import { getKataGoModelPreset } from '../katagoRuntime' +import type { KataGoAssetInstallProgress, KataGoAssetInstallRequest, KataGoAssetInstallResult } from '@main/lib/types' + +export interface KataGoPlatformAsset { + binaryPath: string + sha256?: string +} + +export interface KataGoAssetManifest { + version: number + defaultModelId: string + defaultModelFileName: string + defaultModelDisplayName: string + modelPath: string + modelSha256?: string + supportedPlatforms: Record + notes?: string[] +} + +export interface KataGoAssetStatus { + platformKey: string + manifestFound: boolean + binaryPath: string + binaryFound: boolean + binaryExecutable: boolean + modelPath: string + modelFound: boolean + modelDisplayName: string + ready: boolean + detail: string +} + +function platformKey(): string { + return `${process.platform}-${process.arch}` +} + +function userKatagoRoot(): string | null { + try { + return join(app.getPath('userData'), 'katago') + } catch { + return null + } +} + +function candidateRoots(): string[] { + const roots: string[] = [] + const userRoot = userKatagoRoot() + if (userRoot) { + roots.push(userRoot) + } + if (process.resourcesPath) { + roots.push(join(process.resourcesPath, 'data', 'katago')) + } + roots.push(join(process.cwd(), 'data', 'katago')) + return [...new Set(roots)] +} + +async function exists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +async function executable(path: string): Promise { + if (process.platform === 'win32') return exists(path) + try { + await access(path, constants.X_OK) + return true + } catch { + return false + } +} + +async function sha256(path: string): Promise { + const bytes = await readFile(path) + return createHash('sha256').update(bytes).digest('hex') +} + +function absoluteUrl(value: string): string { + if (value.startsWith('//')) { + return `https:${value}` + } + if (/^https?:\/\//i.test(value)) { + return value + } + return new URL(value, 'https://katagotraining.org').toString() +} + +async function discoverModelDownloadUrl(presetId?: string): Promise { + const preset = getKataGoModelPreset(presetId) + if (preset.downloadUrl) { + return preset.downloadUrl + } + if (/\.bin\.gz($|\?)/i.test(preset.sourceUrl)) { + return preset.sourceUrl + } + const fallback = `https://media.katagotraining.org/uploaded/networks/models/kata1/${preset.fileName}` + try { + const response = await fetch(preset.sourceUrl, { + headers: { 'User-Agent': 'GoMentor KataGo asset installer' } + }) + if (!response.ok) { + return fallback + } + const html = await response.text() + const links = [...html.matchAll(/href=["']([^"']+)["']/gi)].map((match) => absoluteUrl(match[1])) + const exact = links.find((link) => link.includes(preset.fileName)) + if (exact) { + return exact + } + const byNetworkName = links.find((link) => link.includes(preset.networkName) && /\.bin\.gz($|\?)/i.test(link)) + return byNetworkName ?? fallback + } catch { + return fallback + } +} + +async function firstExisting(paths: string[]): Promise { + for (const path of paths) { + if (await exists(path)) { + return path + } + } + return '' +} + +function progressPercent(receivedBytes: number, totalBytes?: number): number | undefined { + if (!totalBytes || totalBytes <= 0) { + return undefined + } + return Math.max(0, Math.min(100, Math.round((receivedBytes / totalBytes) * 1000) / 10)) +} + +async function downloadFile( + url: string, + target: string, + onProgress?: (progress: KataGoAssetInstallProgress) => void +): Promise { + const targetExists = await exists(target) + if (targetExists) { + onProgress?.({ stage: 'downloading-model', message: '官方权重已存在,跳过下载。', percent: 100 }) + return false + } + const tmp = `${target}.download` + await mkdir(dirname(target), { recursive: true }) + await unlink(tmp).catch(() => undefined) + const response = await fetch(url, { + headers: { 'User-Agent': 'GoMentor KataGo asset installer' } + }) + if (!response.ok || !response.body) { + throw new Error(`官方权重下载失败: HTTP ${response.status}`) + } + const totalBytes = Number(response.headers.get('content-length') ?? 0) || undefined + let receivedBytes = 0 + const progressStream = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + receivedBytes += chunk.length + onProgress?.({ + stage: 'downloading-model', + message: '正在下载 KataGo 官方权重。', + receivedBytes, + totalBytes, + percent: progressPercent(receivedBytes, totalBytes) + }) + callback(null, chunk) + } + }) + await pipeline(Readable.fromWeb(response.body as never), progressStream, createWriteStream(tmp)) + await rename(tmp, target) + onProgress?.({ stage: 'downloading-model', message: '官方权重下载完成。', receivedBytes, totalBytes, percent: 100 }) + return true +} + +async function copyPlatformBinaryIfAvailable(root: string, manifest: KataGoAssetManifest, key: string): Promise<{ path: string; copied: boolean }> { + const platform = manifest.supportedPlatforms[key] + if (!platform) { + return { path: '', copied: false } + } + const target = join(root, platform.binaryPath) + if (await exists(target)) { + if (process.platform !== 'win32') { + await chmod(target, 0o755).catch(() => undefined) + } + return { path: target, copied: false } + } + const source = await firstExisting(candidateRoots() + .filter((candidateRoot) => candidateRoot !== root) + .map((candidateRoot) => join(candidateRoot, platform.binaryPath))) + if (!source) { + return { path: target, copied: false } + } + await mkdir(dirname(target), { recursive: true }) + await copyFile(source, target) + if (process.platform !== 'win32') { + await chmod(target, 0o755).catch(() => undefined) + } + return { path: target, copied: true } +} + +export async function readKataGoAssetManifest(): Promise<{ manifest: KataGoAssetManifest | null; root: string }> { + for (const root of candidateRoots()) { + const manifestPath = join(root, 'manifest.json') + if (await exists(manifestPath)) { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as KataGoAssetManifest + return { manifest, root } + } + } + return { manifest: null, root: candidateRoots()[0] } +} + +export async function inspectKataGoAssets(): Promise { + const key = platformKey() + const { manifest, root } = await readKataGoAssetManifest() + if (!manifest) { + return { + platformKey: key, + manifestFound: false, + binaryPath: '', + binaryFound: false, + binaryExecutable: false, + modelPath: '', + modelFound: false, + modelDisplayName: '', + ready: false, + detail: '未找到 data/katago/manifest.json。' + } + } + + const platform = manifest.supportedPlatforms[key] + if (!platform) { + return { + platformKey: key, + manifestFound: true, + binaryPath: '', + binaryFound: false, + binaryExecutable: false, + modelPath: join(root, manifest.modelPath), + modelFound: await exists(join(root, manifest.modelPath)), + modelDisplayName: manifest.defaultModelDisplayName, + ready: false, + detail: `当前平台 ${key} 不在 manifest 支持列表中。` + } + } + + const binaryPath = join(root, platform.binaryPath) + const modelPath = join(root, manifest.modelPath) + const binaryFound = await exists(binaryPath) + const binaryExecutable = binaryFound ? await executable(binaryPath) : false + const modelFound = await exists(modelPath) + let checksumDetail = '' + + try { + if (binaryFound && platform.sha256) { + const actual = await sha256(binaryPath) + if (actual !== platform.sha256) checksumDetail += `KataGo checksum 不匹配;` + } + if (modelFound && manifest.modelSha256) { + const actual = await sha256(modelPath) + if (actual !== manifest.modelSha256) checksumDetail += `模型 checksum 不匹配;` + } + } catch (error) { + checksumDetail += `checksum 校验失败: ${String(error)};` + } + + const ready = binaryFound && binaryExecutable && modelFound && !checksumDetail + const detail = ready + ? `已找到 ${basename(binaryPath)} 和 ${manifest.defaultModelDisplayName}。` + : [ + binaryFound ? '' : `缺少引擎: ${platform.binaryPath}`, + binaryFound && !binaryExecutable ? `引擎不可执行: ${platform.binaryPath}` : '', + modelFound ? '' : `缺少模型: ${manifest.modelPath}`, + checksumDetail + ].filter(Boolean).join(';') + + return { + platformKey: key, + manifestFound: true, + binaryPath, + binaryFound, + binaryExecutable, + modelPath, + modelFound, + modelDisplayName: manifest.defaultModelDisplayName, + ready, + detail + } +} + +export async function installOfficialKataGoModel( + request: KataGoAssetInstallRequest = {}, + onProgress?: (progress: KataGoAssetInstallProgress) => void +): Promise { + const key = platformKey() + const userRoot = userKatagoRoot() + if (!userRoot) { + throw new Error('应用用户目录尚不可用,无法安装 KataGo 官方权重。') + } + const preset = getKataGoModelPreset(request.presetId) + const { manifest: baseManifest } = await readKataGoAssetManifest() + if (!baseManifest) { + throw new Error('缺少 data/katago/manifest.json,无法创建本机资源配置。') + } + onProgress?.({ stage: 'discovering', message: `准备安装 ${preset.label}。` }) + const downloadUrl = await discoverModelDownloadUrl(preset.id) + const modelPath = join(userRoot, 'models', preset.fileName) + const downloadedModel = await downloadFile(downloadUrl, modelPath, onProgress) + + onProgress?.({ stage: 'copying-binary', message: '正在检查当前平台 KataGo 引擎。' }) + const manifest: KataGoAssetManifest = { + ...baseManifest, + defaultModelId: preset.id, + defaultModelFileName: preset.fileName, + defaultModelDisplayName: `KataGo ${preset.label}`, + modelPath: `models/${preset.fileName}`, + modelSha256: await sha256(modelPath).catch(() => '') + } + const binary = await copyPlatformBinaryIfAvailable(userRoot, manifest, key) + onProgress?.({ stage: 'writing-manifest', message: '正在写入本机 KataGo 资源配置。' }) + await mkdir(userRoot, { recursive: true }) + await writeFile(join(userRoot, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8') + + const finalStatus = await inspectKataGoAssets() + const detail = finalStatus.ready + ? `${preset.label} 已安装,可用于胜率图和实时分析。` + : `权重已安装;${finalStatus.detail || '仍需准备当前平台 KataGo 引擎。'}` + onProgress?.({ stage: finalStatus.ready ? 'done' : 'error', message: detail, percent: finalStatus.modelFound ? 100 : undefined }) + return { + ok: finalStatus.ready, + presetId: preset.id, + modelPath, + binaryPath: binary.path, + downloadedModel, + copiedBinary: binary.copied, + detail + } +} diff --git a/src/main/services/katagoBenchmark.ts b/src/main/services/katagoBenchmark.ts new file mode 100644 index 0000000..5226279 --- /dev/null +++ b/src/main/services/katagoBenchmark.ts @@ -0,0 +1,201 @@ +import { spawn } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { basename, join } from 'node:path' +import { appHome, getSettings, setSettings } from '@main/lib/store' +import type { AppSettings, KataGoBenchmarkRequest, KataGoBenchmarkResult, KataGoBenchmarkThreadResult } from '@main/lib/types' +import { resolveKataGoRuntime } from './katagoRuntime' + +function saneInteger(value: number | undefined, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value) || !value) { + return fallback + } + return Math.max(min, Math.min(max, Math.round(value))) +} + +function benchmarkThreadCandidates(requested?: number[]): number[] { + if (requested?.length) { + return [...new Set(requested.map((value) => saneInteger(value, 1, 1, 64)))].sort((a, b) => a - b) + } + const cpuCount = Math.max(1, os.cpus().length) + const maxThreads = Math.min(Math.max(2, cpuCount), 16) + return [...new Set([1, 2, 4, 6, 8, 12, 16, cpuCount].filter((value) => value <= maxThreads && value >= 1))] + .sort((a, b) => a - b) +} + +function writeBenchmarkConfig(settings: AppSettings): string { + const configDir = join(appHome, 'katago', 'configs') + const logDir = join(appHome, 'katago', 'logs') + mkdirSync(configDir, { recursive: true }) + mkdirSync(logDir, { recursive: true }) + const configPath = join(configDir, 'benchmark_builtin.cfg') + const currentThreads = saneInteger(settings.katagoBenchmarkThreads, settings.katagoAnalysisThreads || 2, 1, 64) + const cacheSizePowerOfTwo = saneInteger(settings.katagoCacheSizePowerOfTwo, 20, 16, 28) + writeFileSync( + configPath, + [ + `logDir = ${logDir}`, + 'logAllRequests = false', + 'logSearchInfo = false', + `numSearchThreads = ${currentThreads}`, + `nnCacheSizePowerOfTwo = ${cacheSizePowerOfTwo}`, + '' + ].join('\n'), + 'utf8' + ) + return configPath +} + +function runBenchmarkCommand(command: string, args: string[], timeoutMs: number): Promise { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', (chunk) => { + output += String(chunk) + }) + child.stderr.on('data', (chunk) => { + output += String(chunk) + }) + + return new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + child.kill() + reject(new Error(`KataGo benchmark 超时。\n${tail(output)}`)) + }, timeoutMs) + + child.once('error', (error) => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + reject(error) + }) + + child.once('close', (code) => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + if (code !== 0 && code !== null) { + reject(new Error(tail(output) || `KataGo benchmark exited with ${code}`)) + return + } + resolve(output) + }) + }) +} + +function parseBenchmarkOutput(output: string): { results: KataGoBenchmarkThreadResult[]; recommendedThreads?: number } { + const normalized = output.replace(/\r/g, '\n') + const byThread = new Map() + for (const line of normalized.split('\n')) { + const match = line.match(/numSearchThreads\s*=\s*(\d+):.*?visits\/s\s*=\s*([0-9]+(?:\.[0-9]+)?)/) + if (!match) { + continue + } + const threads = Number.parseInt(match[1], 10) + const visitsPerSecond = Number.parseFloat(match[2]) + if (Number.isFinite(threads) && Number.isFinite(visitsPerSecond)) { + byThread.set(threads, { threads, visitsPerSecond }) + } + } + + const recommendedMatches = [...normalized.matchAll(/numSearchThreads\s*=\s*(\d+):[^\n]*\(recommended\)/g)] + const recommendedThreads = recommendedMatches.length + ? Number.parseInt(recommendedMatches[recommendedMatches.length - 1][1], 10) + : undefined + + return { + results: [...byThread.values()].sort((a, b) => a.threads - b.threads), + recommendedThreads: Number.isFinite(recommendedThreads) ? recommendedThreads : undefined + } +} + +function tunedSettings(recommendedThreads: number, visitsPerSecond: number): Pick< + AppSettings, + | 'katagoAnalysisThreads' + | 'katagoSearchThreadsPerAnalysisThread' + | 'katagoMaxBatchSize' + | 'katagoCacheSizePowerOfTwo' + | 'katagoBenchmarkThreads' + | 'katagoBenchmarkVisitsPerSecond' + | 'katagoBenchmarkUpdatedAt' +> { + const analysisThreads = Math.max(1, Math.min(4, recommendedThreads)) + const searchThreadsPerAnalysisThread = Math.max(1, Math.round(recommendedThreads / analysisThreads)) + const maxBatchSize = Math.max(16, Math.min(128, recommendedThreads >= 12 ? 64 : 32)) + return { + katagoAnalysisThreads: analysisThreads, + katagoSearchThreadsPerAnalysisThread: searchThreadsPerAnalysisThread, + katagoMaxBatchSize: maxBatchSize, + katagoCacheSizePowerOfTwo: 20, + katagoBenchmarkThreads: recommendedThreads, + katagoBenchmarkVisitsPerSecond: visitsPerSecond, + katagoBenchmarkUpdatedAt: new Date().toISOString() + } +} + +function tail(text: string, maxLines = 36): string { + return text.replace(/\r/g, '\n').split('\n').slice(-maxLines).join('\n').trim() +} + +export async function benchmarkKataGo(request: KataGoBenchmarkRequest = {}): Promise { + const settings = getSettings() + const runtime = resolveKataGoRuntime(settings) + if (!runtime.ready) { + throw new Error(`${runtime.status}: ${runtime.notes.join(';')}`) + } + + const benchmarkConfig = writeBenchmarkConfig(settings) + const visits = saneInteger(request.visits, 160, 16, 2000) + const numPositions = saneInteger(request.numPositions, 4, 1, 20) + const secondsPerMove = saneInteger(request.secondsPerMove, 5, 1, 60) + const threadCandidates = benchmarkThreadCandidates(request.threads) + const args = [ + 'benchmark', + '-model', + runtime.katagoModel, + '-config', + benchmarkConfig, + '-v', + String(visits), + '-n', + String(numPositions), + '-time', + String(secondsPerMove), + '-t', + threadCandidates.join(',') + ] + + const output = await runBenchmarkCommand(runtime.katagoBin, args, Math.max(90_000, threadCandidates.length * numPositions * 12_000)) + const parsed = parseBenchmarkOutput(output) + if (parsed.results.length === 0) { + throw new Error(`KataGo benchmark 没有返回速度结果。\n${tail(output)}`) + } + + const fastest = parsed.results.reduce((best, item) => item.visitsPerSecond > best.visitsPerSecond ? item : best, parsed.results[0]) + const recommended = parsed.recommendedThreads + ? parsed.results.find((item) => item.threads === parsed.recommendedThreads) ?? fastest + : fastest + const next = tunedSettings(recommended.threads, recommended.visitsPerSecond) + setSettings(next) + + return { + recommendedThreads: recommended.threads, + visitsPerSecond: recommended.visitsPerSecond, + tested: parsed.results, + analysisThreads: next.katagoAnalysisThreads, + searchThreadsPerAnalysisThread: next.katagoSearchThreadsPerAnalysisThread, + maxBatchSize: next.katagoMaxBatchSize, + cacheSizePowerOfTwo: next.katagoCacheSizePowerOfTwo, + command: `${basename(runtime.katagoBin)} ${args.map((arg) => arg.includes(' ') ? JSON.stringify(arg) : arg).join(' ')}`, + outputTail: tail(output), + updatedAt: next.katagoBenchmarkUpdatedAt + } +} diff --git a/src/main/services/katagoResourceManifest.ts b/src/main/services/katagoResourceManifest.ts new file mode 100644 index 0000000..7f84101 --- /dev/null +++ b/src/main/services/katagoResourceManifest.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +export interface KataGoResourceManifest { + platformKey: string + binaryRelativePath: string + defaultModelRelativePath: string + optionalModelRelativePaths: string[] +} + +export function currentPlatformKey(): string { + return `${process.platform}-${process.arch}` +} + +export function binaryFileName(): string { + return process.platform === 'win32' ? 'katago.exe' : 'katago' +} + +export function expectedBundledManifest(): KataGoResourceManifest { + const platformKey = currentPlatformKey() + return { + platformKey, + binaryRelativePath: join('bin', platformKey, binaryFileName()), + defaultModelRelativePath: join('models', 'kata1-b18c384nbt-s9996604416-d4316597426.bin.gz'), + optionalModelRelativePaths: [ + join('models', 'kata1-zhizi-b28c512nbt-muonfd2.bin.gz') + ] + } +} + +export function validateBundledResourceRoot(root: string): { ok: boolean; missing: string[] } { + const manifest = expectedBundledManifest() + const required = [manifest.binaryRelativePath, manifest.defaultModelRelativePath] + const missing = required.filter((relativePath) => !existsSync(join(root, relativePath))) + return { ok: missing.length === 0, missing } +} diff --git a/src/main/services/katagoRuntime.ts b/src/main/services/katagoRuntime.ts index 72e480c..a944014 100644 --- a/src/main/services/katagoRuntime.ts +++ b/src/main/services/katagoRuntime.ts @@ -7,25 +7,132 @@ import type { AppSettings, KataGoModelPreset, KataGoModelPresetId } from '@main/ export const DEFAULT_KATAGO_MODEL_PRESET: KataGoModelPresetId = 'official-b18-recommended' +const OFFICIAL_NETWORKS_URL = 'https://katagotraining.org/networks/' +const OFFICIAL_NETWORK_CDN = 'https://media.katagotraining.org/uploaded/networks/models/kata1/' + +function officialNetworkUrl(fileName: string): string { + return `${OFFICIAL_NETWORK_CDN}${fileName}` +} + export const KATAGO_MODEL_PRESETS: KataGoModelPreset[] = [ { id: 'official-b18-recommended', - label: '推荐通用 b18', - badge: '最推荐', + label: 'b18 README 日常推荐', + badge: '日常推荐', + group: '18b 官方推荐 / 日常教学', + blockSize: 'b18', + speedTier: 'balanced', + sizeHint: '通用', description: 'KataGo README 推荐的一般首选:强、准、机器压力适中,适合日常教学和自动胜率图。', networkName: 'kata1-b18c384nbt-s9996604416-d4316597426', fileName: 'kata1-b18c384nbt-s9996604416-d4316597426.bin.gz', - sourceUrl: 'https://katagotraining.org/networks/', + sourceUrl: OFFICIAL_NETWORKS_URL, + downloadUrl: officialNetworkUrl('kata1-b18c384nbt-s9996604416-d4316597426.bin.gz'), recommended: true }, + { + id: 'official-b18-stable', + label: 'b18 稳定备选', + badge: '轻快', + group: '18b 官方推荐 / 日常教学', + blockSize: 'b18', + speedTier: 'fast', + sizeHint: '通用', + description: '同为 b18c384nbt 的高分备选权重,适合希望保持轻快分析速度的机器。', + networkName: 'kata1-b18c384nbt-s9967423488-d4308703317', + fileName: 'kata1-b18c384nbt-s9967423488-d4308703317.bin.gz', + sourceUrl: OFFICIAL_NETWORKS_URL, + downloadUrl: officialNetworkUrl('kata1-b18c384nbt-s9967423488-d4308703317.bin.gz'), + recommended: false + }, + { + id: 'official-b20-strong', + label: 'b20 强力轻量', + badge: '20b 强', + group: '20b 快速分析 / 旧机友好', + blockSize: 'b20', + speedTier: 'balanced', + sizeHint: '较快', + description: 'b20c256x2 中较高评分的一档,适合 CPU 或入门 GPU 做快速胜率图和日常复盘。', + networkName: 'kata1-b20c256x2-s5303129600-d1228401921', + fileName: 'kata1-b20c256x2-s5303129600-d1228401921.bin.gz', + sourceUrl: OFFICIAL_NETWORKS_URL, + downloadUrl: officialNetworkUrl('kata1-b20c256x2-s5303129600-d1228401921.bin.gz'), + recommended: false + }, + { + id: 'official-b20-balanced', + label: 'b20 平衡备选', + badge: '20b 快', + group: '20b 快速分析 / 旧机友好', + blockSize: 'b20', + speedTier: 'fast', + sizeHint: '较快', + description: 'b20c256x2 的轻量备选,更偏速度和兼容性,适合先把整盘胜率曲线快速跑出来。', + networkName: 'kata1-b20c256x2-s5055114240-d1149032340', + fileName: 'kata1-b20c256x2-s5055114240-d1149032340.bin.gz', + sourceUrl: OFFICIAL_NETWORKS_URL, + downloadUrl: officialNetworkUrl('kata1-b20c256x2-s5055114240-d1149032340.bin.gz'), + recommended: false + }, { id: 'official-b28-strong', - label: '强力精读 b28', - badge: '官网强力', - description: 'katagotraining.org 当前 strongest confidently-rated 档位,更适合关键局面精读,速度会慢一些。', + label: 'zhizi b28 官网最强', + badge: '官网最强', + group: '官网推荐 zhizi 模型', + blockSize: 'b28', + speedTier: 'strong', + sizeHint: '较慢', + description: 'katagotraining.org 当前 Strongest confidently-rated network:zhizi b28,更适合关键局面精读,速度会慢一些。', networkName: 'kata1-zhizi-b28c512nbt-muonfd2', fileName: 'kata1-zhizi-b28c512nbt-muonfd2.bin.gz', - sourceUrl: 'https://katagotraining.org/networks/', + sourceUrl: OFFICIAL_NETWORKS_URL, + downloadUrl: officialNetworkUrl('kata1-zhizi-b28c512nbt-muonfd2.bin.gz'), + recommended: false + }, + { + id: 'official-b28-latest', + label: 'b28 最新训练线', + badge: '28b 新', + group: '28b 高强度精读', + blockSize: 'b28', + speedTier: 'strong', + sizeHint: '较慢', + description: '官方网络列表中较新的 b28c512nbt 训练线,适合高配置机器做更深的局面判断。', + networkName: 'kata1-b28c512nbt-s12763923712-d5805955894', + fileName: 'kata1-b28c512nbt-s12763923712-d5805955894.bin.gz', + sourceUrl: OFFICIAL_NETWORKS_URL, + downloadUrl: officialNetworkUrl('kata1-b28c512nbt-s12763923712-d5805955894.bin.gz'), + recommended: false + }, + { + id: 'official-b40-latest', + label: 'zhizi b40 官网最新', + badge: '官网最新', + group: '官网推荐 zhizi 模型', + blockSize: 'b40', + speedTier: 'maximum', + sizeHint: '很慢', + description: 'katagotraining.org 当前 Latest network:zhizi b40,更重、更强,适合高端 GPU 对关键局面做深度精读。', + networkName: 'kata1-zhizi-b40c768nbt-fdx6c', + fileName: 'kata1-zhizi-b40c768nbt-fdx6c.bin.gz', + sourceUrl: OFFICIAL_NETWORKS_URL, + downloadUrl: officialNetworkUrl('kata1-zhizi-b40c768nbt-fdx6c.bin.gz'), + recommended: false + }, + { + id: 'official-b40-classic', + label: 'b40 经典 c256', + badge: '40b 稳', + group: '40b 旗舰 / 高配机器', + blockSize: 'b40', + speedTier: 'maximum', + sizeHint: '很慢', + description: '经典 b40c256 高强度权重,适合需要 40b 级别判断但希望资源压力低于最新旗舰的场景。', + networkName: 'kata1-b40c256-s12860905472-d3197353276', + fileName: 'kata1-b40c256-s12860905472-d3197353276.bin.gz', + sourceUrl: OFFICIAL_NETWORKS_URL, + downloadUrl: officialNetworkUrl('kata1-b40c256-s12860905472-d3197353276.bin.gz'), recommended: false } ] @@ -71,6 +178,10 @@ function globModelFiles(directory: string, pattern: RegExp): string[] { } } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + export function getKataGoModelPreset(id?: string): KataGoModelPreset { return KATAGO_MODEL_PRESETS.find((preset) => preset.id === id) ?? KATAGO_MODEL_PRESETS[0] } @@ -98,7 +209,7 @@ function binaryCandidates(): string[] { const file = platformBinaryName() const roots = resourceRoots() return [ - process.env.KATASENSEI_KATAGO_BIN ?? '', + process.env.GOMENTOR_KATAGO_BIN ?? '', ...roots.flatMap((root) => [ join(root, 'bin', platformKey(), file), join(root, 'bin', process.platform, file), @@ -122,28 +233,45 @@ function modelDirectories(): string[] { function modelCandidates(preset: KataGoModelPreset, settings?: AppSettings): string[] { const directories = modelDirectories() - const presetPatterns = - preset.id === 'official-b18-recommended' - ? [/^kata1-b18c384nbt.*\.bin\.gz$/] - : [/^kata1-zhizi-b28c512nbt.*\.bin\.gz$/, /^kata1-b28c512nbt.*\.bin\.gz$/] + const blockToken = preset.blockSize || preset.networkName.match(/b\d+/)?.[0] || '' + const presetPatterns = [ + new RegExp(`^${escapeRegExp(preset.networkName)}\\.bin\\.gz$`), + blockToken ? new RegExp(`^kata1-.*${escapeRegExp(blockToken)}.*\\.bin\\.gz$`) : null + ].filter((pattern): pattern is RegExp => Boolean(pattern)) const selectedPresetFiles = directories.map((directory) => join(directory, preset.fileName)) const matchingPresetFiles = directories.flatMap((directory) => presetPatterns.flatMap((pattern) => globModelFiles(directory, pattern))) const compatibilityFiles = [ settings?.katagoModel ?? '', ...directories.map((directory) => join(directory, 'latest-kata1.bin.gz')), - ...directories.flatMap((directory) => globModelFiles(directory, /^kata1-(zhizi-)?b28c512nbt.*\.bin\.gz$/)), + ...directories.flatMap((directory) => globModelFiles(directory, /^kata1-.*b40.*\.bin\.gz$/)), + ...directories.flatMap((directory) => globModelFiles(directory, /^kata1-.*b28.*\.bin\.gz$/)), + ...directories.flatMap((directory) => globModelFiles(directory, /^kata1-.*b20.*\.bin\.gz$/)), ...directories.flatMap((directory) => globModelFiles(directory, /^kata1-b18c384nbt.*\.bin\.gz$/)) ] return [...selectedPresetFiles, ...matchingPresetFiles, ...compatibilityFiles] } -function ensureAnalysisConfig(): string { +function saneInteger(value: number | undefined, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value) || !value) { + return fallback + } + return Math.max(min, Math.min(max, Math.round(value))) +} + +function defaultAnalysisThreads(): number { + return Math.max(1, Math.min(4, os.cpus().length - 2)) +} + +function ensureAnalysisConfig(settings?: AppSettings): string { const configDir = join(appHome, 'katago', 'configs') mkdirSync(configDir, { recursive: true }) const logDir = join(appHome, 'katago', 'logs') mkdirSync(logDir, { recursive: true }) const configPath = join(configDir, 'analysis_builtin.cfg') - const analysisThreads = Math.max(2, Math.min(4, os.cpus().length - 2)) + const analysisThreads = saneInteger(settings?.katagoAnalysisThreads, defaultAnalysisThreads(), 1, 16) + const searchThreadsPerAnalysisThread = saneInteger(settings?.katagoSearchThreadsPerAnalysisThread, 1, 1, 16) + const maxBatchSize = saneInteger(settings?.katagoMaxBatchSize, 32, 1, 256) + const cacheSizePowerOfTwo = saneInteger(settings?.katagoCacheSizePowerOfTwo, 20, 16, 28) writeFileSync( configPath, [ @@ -153,9 +281,9 @@ function ensureAnalysisConfig(): string { 'analysisPVLen = 12', 'reportAnalysisWinratesAs = BLACK', `numAnalysisThreads = ${analysisThreads}`, - 'numSearchThreadsPerAnalysisThread = 1', - 'nnMaxBatchSize = 32', - 'nnCacheSizePowerOfTwo = 20', + `numSearchThreadsPerAnalysisThread = ${searchThreadsPerAnalysisThread}`, + `nnMaxBatchSize = ${maxBatchSize}`, + `nnCacheSizePowerOfTwo = ${cacheSizePowerOfTwo}`, '' ].join('\n'), 'utf8' @@ -166,7 +294,7 @@ function ensureAnalysisConfig(): string { export function resolveKataGoRuntime(settings?: AppSettings): KataGoRuntime { const modelPreset = getKataGoModelPreset(settings?.katagoModelPreset) const katagoBin = firstExisting([...binaryCandidates(), settings?.katagoBin ?? '']) - const katagoConfig = ensureAnalysisConfig() + const katagoConfig = ensureAnalysisConfig(settings) const katagoModel = firstExisting(modelCandidates(modelPreset, settings)) const notes: string[] = [] diff --git a/src/main/services/knowledge.ts b/src/main/services/knowledge.ts index fb7e49e..6d83e7b 100644 --- a/src/main/services/knowledge.ts +++ b/src/main/services/knowledge.ts @@ -1,7 +1,15 @@ import { app } from 'electron' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import type { CoachUserLevel, GameMove, KnowledgePacket } from '@main/lib/types' +import type { CoachUserLevel, GameMove, KnowledgeMatch, KnowledgePacket } from '@main/lib/types' +import { + formatPatternForPrompt, + loadKnowledgePatternCards, + searchKnowledgePatterns, + type PatternRegion, + type PatternSearchMatch +} from './knowledge/patterns' +import { formatKnowledgeMatchForPrompt, searchKnowledgeMatchEngine, type BoardSnapshotStone, type LocalWindow } from './knowledge/matchEngine' interface KnowledgeEntry { id: string @@ -14,20 +22,44 @@ interface KnowledgeEntry { content?: string } +interface P0KnowledgeCard { + id: string + title: string + kind: string + phase: Array<'opening' | 'middlegame' | 'endgame'> + errorTypes: string[] + tags: string[] + katagoSignals: string[] + boardSignals: string[] + summary: string + coachShort: string + coachLong: string + drill: string +} + export interface KnowledgeQuery { + text?: string moveNumber: number totalMoves: number boardSize: number recentMoves: GameMove[] userLevel: CoachUserLevel + studentLevel?: CoachUserLevel + playerColor?: 'B' | 'W' + boardSnapshot?: BoardSnapshotStone[] + localWindows?: LocalWindow[] lossScore?: number judgement?: string contextTags?: string[] + playedMove?: string + candidateMoves?: string[] + principalVariation?: string[] maxResults?: number } let cachedDataRoot = '' let cachedEntries: KnowledgeEntry[] | null = null +let cachedP0Cards: P0KnowledgeCard[] | null = null function dataRoot(): string { if (app.isPackaged) { @@ -67,6 +99,23 @@ function loadEntries(): KnowledgeEntry[] { } } +function loadP0Cards(root: string): P0KnowledgeCard[] { + if (cachedP0Cards) { + return cachedP0Cards + } + const cardsPath = join(root, 'knowledge', 'p0-cards.json') + if (!existsSync(cardsPath)) { + cachedP0Cards = [] + return cachedP0Cards + } + try { + cachedP0Cards = JSON.parse(readFileSync(cardsPath, 'utf8')) as P0KnowledgeCard[] + } catch { + cachedP0Cards = [] + } + return cachedP0Cards +} + export function detectGamePhase(moveNumber: number, totalMoves: number): 'opening' | 'middle' | 'endgame' { const ratio = totalMoves > 0 ? moveNumber / totalMoves : 0 if (moveNumber <= 40 || ratio <= 0.2) { @@ -78,6 +127,14 @@ export function detectGamePhase(moveNumber: number, totalMoves: number): 'openin return 'endgame' } +function p0Phase(phase: ReturnType): 'opening' | 'middlegame' | 'endgame' { + return phase === 'middle' ? 'middlegame' : phase +} + +function patternRegion(region: ReturnType): PatternRegion { + return region +} + function detectBoardRegion(recentMoves: GameMove[], boardSize: number): 'corner' | 'side' | 'center' { if (recentMoves.length === 0) { return 'center' @@ -136,10 +193,31 @@ function selectedBody(content: string, maxChars: number): string { } export function searchKnowledge(query: KnowledgeQuery): KnowledgePacket[] { + const root = dataRoot() const entries = loadEntries() const phase = detectGamePhase(query.moveNumber, query.totalMoves) const region = detectBoardRegion(query.recentMoves, query.boardSize) const scored: Array<{ entry: KnowledgeEntry; score: number }> = [] + const p0Scored: Array<{ card: P0KnowledgeCard; score: number }> = [] + const patternScored: PatternSearchMatch[] = searchKnowledgePatterns(loadKnowledgePatternCards(root), { + userLevel: query.userLevel, + phase: p0Phase(phase), + region: patternRegion(region), + boardSize: query.boardSize, + moveNumber: query.moveNumber, + recentMoves: query.recentMoves, + contextTags: query.contextTags, + text: query.text, + playedMove: query.playedMove, + candidateMoves: query.candidateMoves, + principalVariation: query.principalVariation, + lossScore: query.lossScore, + judgement: query.judgement + }) + const knowledgeMatches = searchKnowledgeMatches({ + ...query, + maxResults: 6 + }) for (const entry of entries) { if (!entry.content || !entry.levels.includes(query.userLevel)) { @@ -188,8 +266,35 @@ export function searchKnowledge(query: KnowledgeQuery): KnowledgePacket[] { } } - scored.sort((a, b) => b.score - a.score) - return scored.slice(0, query.maxResults ?? 5).map(({ entry, score }) => ({ + const contextTags = new Set((query.contextTags ?? []).map((tag) => tag.toLowerCase())) + const p0WantedPhase = p0Phase(phase) + for (const card of loadP0Cards(root)) { + let score = 0 + if (card.phase.includes(p0WantedPhase)) { + score += 4 + } + for (const tag of card.tags) { + if (contextTags.has(tag.toLowerCase())) { + score += 3 + } + } + for (const errorType of card.errorTypes) { + if ([...contextTags].some((tag) => tag.includes(errorType.toLowerCase()) || errorType.toLowerCase().includes(tag))) { + score += 3 + } + } + if ((query.lossScore ?? 0) >= 3 && ['mistake', 'blunder'].includes(String(query.judgement))) { + score += card.kind === 'error_type' || card.kind === 'review_method' ? 2 : 1 + } + if (region !== 'center' && card.boardSignals.some((signal) => signal.includes(region))) { + score += 1 + } + if (score > 0) { + p0Scored.push({ card, score }) + } + } + + const markdownPackets = scored.map(({ entry, score }) => ({ id: entry.id, title: extractTitle(entry.content!) || entry.id, category: entry.category, @@ -199,4 +304,46 @@ export function searchKnowledge(query: KnowledgeQuery): KnowledgePacket[] { selectedBody: selectedBody(entry.content!, 900), score })) + + const p0Packets = p0Scored.map(({ card, score }) => ({ + id: card.id, + title: card.title, + category: card.kind, + phase: card.phase.join(','), + tags: [...new Set([...card.tags, ...card.errorTypes])], + summary: card.summary, + selectedBody: [card.coachShort, card.coachLong, `训练: ${card.drill}`].join('\n'), + score + })) + + const patternPackets = patternScored.map((match) => ({ + id: match.card.id, + title: match.card.title, + category: match.card.category, + phase: match.card.phase.join(','), + tags: [...new Set([...match.card.tags, match.card.patternType, match.confidence])], + summary: match.card.teaching.recognition, + selectedBody: formatPatternForPrompt(match), + score: match.score + })) + const matchPackets = knowledgeMatches + .filter((match) => match.confidence !== 'weak') + .map((match) => ({ + id: match.id, + title: match.title, + category: match.matchType, + phase: phase, + tags: [...new Set([match.matchType, match.confidence, ...match.teachingPayload.keyVariations.slice(0, 2)])], + summary: match.teachingPayload.recognition, + selectedBody: formatKnowledgeMatchForPrompt(match), + score: match.score + 5 + })) + + return [...markdownPackets, ...p0Packets, ...patternPackets, ...matchPackets] + .sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)) + .slice(0, query.maxResults ?? 4) +} + +export function searchKnowledgeMatches(query: KnowledgeQuery): KnowledgeMatch[] { + return searchKnowledgeMatchEngine(dataRoot(), query) } diff --git a/src/main/services/knowledge/matchEngine.ts b/src/main/services/knowledge/matchEngine.ts new file mode 100644 index 0000000..09ee44a --- /dev/null +++ b/src/main/services/knowledge/matchEngine.ts @@ -0,0 +1,979 @@ +import type { + CoachUserLevel, + GameMove, + KnowledgeMatch, + KnowledgeMatchConfidence, + KnowledgeMatchType, + RecommendedProblem +} from '@main/lib/types' +import { + loadKnowledgeTrainingLibrary, + type JosekiLine, + type KnowledgeTrainingLibrary, + type LifeDeathProblem, + type TesujiProblem, + type TrainingRegion +} from './training' +import { + formatPatternForPrompt, + loadKnowledgePatternCards, + searchKnowledgePatterns, + type PatternPhase, + type PatternRegion +} from './patterns' + +export interface BoardSnapshotStone { + color: 'B' | 'W' + point: string +} + +export interface LocalWindow { + anchor: string + stones: BoardSnapshotStone[] +} + +export interface KnowledgeMatchQuery { + text?: string + moveNumber: number + totalMoves: number + boardSize: number + recentMoves: GameMove[] + userLevel: CoachUserLevel + studentLevel?: CoachUserLevel + playerColor?: 'B' | 'W' + lossScore?: number + judgement?: string + contextTags?: string[] + playedMove?: string + candidateMoves?: string[] + principalVariation?: string[] + boardSnapshot?: BoardSnapshotStone[] + localWindows?: LocalWindow[] + maxResults?: number +} + +interface QueryFeatures { + phase: PatternPhase + region: TrainingRegion + tokens: Set + explicitTokens: Set + intentTypes: Set + explicitIntentTypes: Set + moveFeatures: Set + candidateFeatures: Set + pvFeatures: Set + allPoints: Set +} + +type ProblemEntry = LifeDeathProblem | TesujiProblem +type RelativeTransform = (dx: number, dy: number) => [number, number] + +interface RelativeStone { + dx: number + dy: number + color: 'B' | 'W' +} + +interface GeometryMatchResult { + score: number + ratio: number + matched: number + expected: number + libertyScore: number + transform: string + colorMode: 'same-color' | 'color-swapped' + queryAnchor: string + problemAnchor: string +} + +interface AnchorLibertyProfile { + adjacent: Record<'B' | 'W' | 'empty' | 'edge', number> + groups: Record<'B' | 'W', number> + minLiberties: Partial> +} + +const TOKEN_SPLIT = /[,。!?、;:,.!?;:()\[\]【】\s/_-]+/ +const LOCAL_SHAPE_RADIUS = 4 +const CONFIDENCE_RANK: Record = { + exact: 4, + strong: 3, + partial: 2, + weak: 1 +} + +const INTENT_KEYWORDS: Record, string[]> = { + joseki: [ + '定式', + 'joseki', + '星位', + '小目', + '点三三', + '三三', + '挂角', + '守角', + '夹攻', + '低挂', + '高挂', + '大雪崩', + '雪崩', + '大斜', + '太斜', + '双飞燕', + '妖刀', + '托退', + '中国流', + '三连星', + '小林流', + '布局', + '开局' + ], + life_death: [ + '死活', + '做活', + '杀棋', + '活棋', + '真眼', + '假眼', + '眼形', + '急所', + '对杀', + '劫活', + '双活', + '曲四', + '刀把五', + '梅花六', + '葡萄六', + '板六', + '金鸡独立', + '直三', + '弯三', + '中手', + '破眼', + '扑入' + ], + tesuji: [ + '手筋', + '倒扑', + '吃回', + '接不归', + '老鼠偷油', + '征子', + '枷', + '滚打包收', + '挖', + '扭十字', + '断', + '窥', + '扳头', + '双打', + '弃子', + '扑' + ] +} + +const BROAD_TRAINING_TAGS = new Set([ + '角部', + '边上', + '中腹', + '中心', + '定式', + '布局', + '方向', + '大场', + '变化', + '价值判断', + '局部轻重', + '先手价值', + '实地', + '外势', + '问题手', + '急所', + '死活', + '手筋', + '形', + '形状', + '先手', + '后手', + 'ai定式' +]) + +const LOCAL_SHAPE_TRANSFORMS: Array<{ name: string; apply: RelativeTransform }> = [ + { name: 'identity', apply: (dx, dy) => [dx, dy] }, + { name: 'rotate90', apply: (dx, dy) => [-dy, dx] }, + { name: 'rotate180', apply: (dx, dy) => [-dx, -dy] }, + { name: 'rotate270', apply: (dx, dy) => [dy, -dx] }, + { name: 'mirror-x', apply: (dx, dy) => [-dx, dy] }, + { name: 'mirror-y', apply: (dx, dy) => [dx, -dy] }, + { name: 'mirror-main-diagonal', apply: (dx, dy) => [dy, dx] }, + { name: 'mirror-anti-diagonal', apply: (dx, dy) => [-dy, -dx] } +] + +function phaseFromMove(moveNumber: number, totalMoves: number): PatternPhase { + const ratio = totalMoves > 0 ? moveNumber / totalMoves : 0 + if (moveNumber <= 40 || ratio <= 0.2) return 'opening' + if (ratio <= 0.72) return 'middlegame' + return 'endgame' +} + +function normalizeTokens(values: Array): string[] { + return values + .flatMap((value) => String(value ?? '').split(TOKEN_SPLIT)) + .map((token) => token.trim().toLowerCase()) + .filter(Boolean) +} + +function gtpToPoint(point: string, boardSize: number): { row: number; col: number } | null { + const match = point.trim().toUpperCase().match(/^([A-HJ-Z])(\d{1,2})$/) + if (!match) return null + const letters = 'ABCDEFGHJKLMNOPQRSTUVWXYZ' + const col = letters.indexOf(match[1]) + const number = Number(match[2]) + if (col < 0 || col >= boardSize || number < 1 || number > boardSize) return null + return { col, row: boardSize - number } +} + +function addPointFeatures(features: Set, row: number, col: number, boardSize: number): void { + const x = Math.min(col, boardSize - 1 - col) + const y = Math.min(row, boardSize - 1 - row) + const minEdge = Math.min(x, y) + const maxEdge = Math.max(x, y) + + if (x <= 5 && y <= 5) features.add('corner') + else if (minEdge <= 3) features.add('side') + else features.add('center') + + if (minEdge === 0) features.add('first-line') + if (minEdge === 1) features.add('second-line') + if (minEdge === 2) features.add('third-line') + if (minEdge === 3) features.add('fourth-line') + if (x === 3 && y === 3) features.add('4-4') + if (x === 2 && y === 2) features.add('3-3') + if ((x === 2 && y === 3) || (x === 3 && y === 2)) features.add('3-4') + if (minEdge <= 3 && maxEdge >= 4 && maxEdge <= 6) features.add('approach') + if (minEdge <= 2 && maxEdge <= 5) features.add('eye-shape') +} + +function addFeaturesFromGtp(points: string[] | undefined, boardSize: number): Set { + const features = new Set() + for (const point of points ?? []) { + const parsed = gtpToPoint(point, boardSize) + if (parsed) { + addPointFeatures(features, parsed.row, parsed.col, boardSize) + } + } + return features +} + +function addFeaturesFromMoves(moves: GameMove[], boardSize: number): Set { + const features = new Set() + for (const move of moves) { + if (move.row !== null && move.col !== null) { + addPointFeatures(features, move.row, move.col, boardSize) + } else if (move.gtp) { + for (const feature of addFeaturesFromGtp([move.gtp], boardSize)) features.add(feature) + } + } + if (moves.length >= 2) { + const last = moves[moves.length - 1] + const previous = moves[moves.length - 2] + const lastPoint = last.row !== null && last.col !== null ? { row: last.row, col: last.col } : gtpToPoint(last.gtp, boardSize) + const prevPoint = previous.row !== null && previous.col !== null ? { row: previous.row, col: previous.col } : gtpToPoint(previous.gtp, boardSize) + if (lastPoint && prevPoint) { + const dx = Math.abs(lastPoint.col - prevPoint.col) + const dy = Math.abs(lastPoint.row - prevPoint.row) + if (dx + dy === 1) features.add('contact') + if ((dx === 1 && dy === 2) || (dx === 2 && dy === 1)) features.add('knight-move') + if ((dx === 0 && dy === 2) || (dx === 2 && dy === 0)) features.add('jump') + } + } + return features +} + +function detectRegion(query: KnowledgeMatchQuery): TrainingRegion { + const points = [ + query.playedMove, + ...(query.candidateMoves ?? []).slice(0, 3), + ...(query.principalVariation ?? []).slice(0, 5), + ...query.recentMoves.map((move) => move.gtp) + ].filter(Boolean) as string[] + let corner = 0 + let side = 0 + let center = 0 + for (const point of points) { + const parsed = gtpToPoint(point, query.boardSize) + if (!parsed) continue + const x = Math.min(parsed.col, query.boardSize - 1 - parsed.col) + const y = Math.min(parsed.row, query.boardSize - 1 - parsed.row) + if (x <= 5 && y <= 5) corner += 1 + else if (Math.min(x, y) <= 3) side += 1 + else center += 1 + } + if (corner >= side && corner >= center) return 'corner' + if (side >= center) return 'side' + return 'center' +} + +function buildFeatures(query: KnowledgeMatchQuery): QueryFeatures { + const explicitTokens = new Set(normalizeTokens([query.text])) + const tokens = new Set(normalizeTokens([ + query.text, + query.judgement, + ...(query.contextTags ?? []) + ])) + const moveFeatures = new Set([ + ...addFeaturesFromMoves(query.recentMoves.slice(-10), query.boardSize), + ...addFeaturesFromGtp(query.playedMove ? [query.playedMove] : [], query.boardSize) + ]) + const candidateFeatures = addFeaturesFromGtp(query.candidateMoves, query.boardSize) + const pvFeatures = addFeaturesFromGtp(query.principalVariation, query.boardSize) + const allPoints = new Set([ + query.playedMove, + ...(query.candidateMoves ?? []), + ...(query.principalVariation ?? []), + ...query.recentMoves.map((move) => move.gtp), + ...(query.boardSnapshot ?? []).map((stone) => stone.point), + ...(query.localWindows ?? []).flatMap((window) => [window.anchor, ...window.stones.map((stone) => stone.point)]) + ].filter(Boolean) as string[]) + + return { + phase: phaseFromMove(query.moveNumber, query.totalMoves), + region: detectRegion(query), + tokens, + explicitTokens, + intentTypes: detectIntentTypes(tokens), + explicitIntentTypes: detectIntentTypes(explicitTokens), + moveFeatures, + candidateFeatures, + pvFeatures, + allPoints + } +} + +function addOverlapScore(values: string[], tokens: Set, weight: number, reasons: string[], prefix: string): number { + let score = 0 + for (const value of values) { + const normalized = value.toLowerCase() + if (tokenSetHas(tokens, normalized)) { + score += weight + reasons.push(`${prefix}:${value}`) + } + } + return score +} + +function tokenSetHas(tokens: Set, value: string): boolean { + const normalized = value.toLowerCase() + return tokens.has(normalized) || [...tokens].some((token) => normalized.includes(token) || token.includes(normalized)) +} + +function hasSpecificTextHit(values: string[], tokens: Set): boolean { + return values + .filter((value) => !BROAD_TRAINING_TAGS.has(value.toLowerCase())) + .some((value) => tokenSetHas(tokens, value)) +} + +function detectIntentTypes(tokens: Set): Set { + const intentTypes = new Set() + for (const [type, keywords] of Object.entries(INTENT_KEYWORDS) as Array<[Exclude, string[]]>) { + if (keywords.some((keyword) => tokenSetHas(tokens, keyword))) { + intentTypes.add(type) + } + } + return intentTypes +} + +function featureOverlap(values: string[], features: Set, weight: number, reasons: string[], prefix: string): number { + let score = 0 + for (const value of values) { + const normalized = value.toLowerCase() + if (features.has(normalized) || [...features].some((feature) => normalized.includes(feature) || feature.includes(normalized))) { + score += weight + reasons.push(`${prefix}:${value}`) + } + } + return score +} + +function sequenceOverlap(sequence: string[], points: Set, reasons: string[]): number { + let overlap = 0 + for (const point of sequence) { + if (points.has(point)) { + overlap += 1 + } + } + if (overlap > 0) { + reasons.push(`sequence-overlap:${overlap}`) + } + return overlap +} + +function uniqueValidPoints(points: Array, boardSize: number): string[] { + const seen = new Set() + const values: string[] = [] + for (const point of points) { + if (!point || seen.has(point)) continue + if (!gtpToPoint(point, boardSize)) continue + seen.add(point) + values.push(point) + } + return values +} + +function stonesNearAnchor(stones: BoardSnapshotStone[], anchor: string, boardSize: number): RelativeStone[] { + const anchorPoint = gtpToPoint(anchor, boardSize) + if (!anchorPoint) return [] + const seen = new Set() + const relatives: RelativeStone[] = [] + for (const stone of stones) { + const point = gtpToPoint(stone.point, boardSize) + if (!point) continue + const dx = point.col - anchorPoint.col + const dy = point.row - anchorPoint.row + if (dx === 0 && dy === 0) continue + if (Math.max(Math.abs(dx), Math.abs(dy)) > LOCAL_SHAPE_RADIUS) continue + const key = `${stone.color}:${dx}:${dy}` + if (seen.has(key)) continue + seen.add(key) + relatives.push({ dx, dy, color: stone.color }) + } + return relatives +} + +function stoneKey(stone: RelativeStone, transform: RelativeTransform, swapColors: boolean): string { + const [dx, dy] = transform(stone.dx, stone.dy) + const color = swapColors ? (stone.color === 'B' ? 'W' : 'B') : stone.color + return `${color}:${dx}:${dy}` +} + +function queryStonesForAnchor(query: KnowledgeMatchQuery, anchor: string): BoardSnapshotStone[] { + const localWindow = (query.localWindows ?? []).find((window) => window.anchor === anchor) + if (localWindow && localWindow.stones.length > 0) { + return localWindow.stones + } + return query.boardSnapshot ?? [] +} + +function boardKey(row: number, col: number): string { + return `${row},${col}` +} + +function localBoard(stones: BoardSnapshotStone[], boardSize: number): Map { + const board = new Map() + for (const stone of stones) { + const point = gtpToPoint(stone.point, boardSize) + if (point) { + board.set(boardKey(point.row, point.col), stone.color) + } + } + return board +} + +function neighbors(row: number, col: number, boardSize: number): Array<{ row: number; col: number }> { + return [ + { row: row - 1, col }, + { row: row + 1, col }, + { row, col: col - 1 }, + { row, col: col + 1 } + ].filter((point) => point.row >= 0 && point.col >= 0 && point.row < boardSize && point.col < boardSize) +} + +function collectLocalGroup(board: Map, row: number, col: number, boardSize: number): Array<{ row: number; col: number }> { + const color = board.get(boardKey(row, col)) + if (!color) return [] + const seen = new Set() + const group: Array<{ row: number; col: number }> = [] + const stack = [{ row, col }] + while (stack.length > 0) { + const current = stack.pop()! + const key = boardKey(current.row, current.col) + if (seen.has(key) || board.get(key) !== color) continue + seen.add(key) + group.push(current) + for (const next of neighbors(current.row, current.col, boardSize)) { + if (board.get(boardKey(next.row, next.col)) === color) { + stack.push(next) + } + } + } + return group +} + +function localLibertyCount(board: Map, group: Array<{ row: number; col: number }>, boardSize: number): number { + const liberties = new Set() + for (const stone of group) { + for (const next of neighbors(stone.row, stone.col, boardSize)) { + const key = boardKey(next.row, next.col) + if (!board.has(key)) { + liberties.add(key) + } + } + } + return liberties.size +} + +function anchorLibertyProfile(stones: BoardSnapshotStone[], anchor: string, boardSize: number): AnchorLibertyProfile | null { + const anchorPoint = gtpToPoint(anchor, boardSize) + if (!anchorPoint) return null + const board = localBoard(stones, boardSize) + if (board.has(boardKey(anchorPoint.row, anchorPoint.col))) { + return null + } + + const profile: AnchorLibertyProfile = { + adjacent: { B: 0, W: 0, empty: 0, edge: 4 }, + groups: { B: 0, W: 0 }, + minLiberties: {} + } + const visitedGroups = new Set() + const validNeighbors = neighbors(anchorPoint.row, anchorPoint.col, boardSize) + profile.adjacent.edge = 4 - validNeighbors.length + + for (const next of validNeighbors) { + const key = boardKey(next.row, next.col) + const color = board.get(key) + if (!color) { + profile.adjacent.empty += 1 + continue + } + + profile.adjacent[color] += 1 + const group = collectLocalGroup(board, next.row, next.col, boardSize) + const groupKey = group.map((stone) => boardKey(stone.row, stone.col)).sort().join('|') + if (visitedGroups.has(groupKey)) continue + visitedGroups.add(groupKey) + profile.groups[color] += 1 + const liberties = localLibertyCount(board, group, boardSize) + profile.minLiberties[color] = Math.min(profile.minLiberties[color] ?? liberties, liberties) + } + + return profile +} + +function swappedColor(color: 'B' | 'W'): 'B' | 'W' { + return color === 'B' ? 'W' : 'B' +} + +function profileValue(profile: AnchorLibertyProfile, color: 'B' | 'W', field: 'adjacent' | 'groups' | 'minLiberties', swapColors: boolean): number | undefined { + const sourceColor = swapColors ? swappedColor(color) : color + return field === 'minLiberties' ? profile.minLiberties[sourceColor] : profile[field][sourceColor] +} + +function closeCountScore(queryValue: number | undefined, expectedValue: number | undefined, exactScore: number, nearScore: number): number { + const queryCount = queryValue ?? 0 + const expectedCount = expectedValue ?? 0 + if (queryCount === expectedCount) return exactScore + if (Math.abs(queryCount - expectedCount) <= 1) return nearScore + return 0 +} + +function anchorProfileScore(queryProfile: AnchorLibertyProfile, problemProfile: AnchorLibertyProfile, swapColors: boolean): number { + let score = 0 + score += closeCountScore(queryProfile.adjacent.empty, problemProfile.adjacent.empty, 2, 1) + score += closeCountScore(queryProfile.adjacent.edge, problemProfile.adjacent.edge, 2, 1) + for (const color of ['B', 'W'] as const) { + score += closeCountScore(queryProfile.adjacent[color], profileValue(problemProfile, color, 'adjacent', swapColors), 2, 1) + score += closeCountScore(queryProfile.groups[color], profileValue(problemProfile, color, 'groups', swapColors), 1, 0) + const queryLiberties = queryProfile.minLiberties[color] + const expectedLiberties = profileValue(problemProfile, color, 'minLiberties', swapColors) + if (queryLiberties !== undefined || expectedLiberties !== undefined) { + score += closeCountScore(queryLiberties, expectedLiberties, 2, 1) + } + } + return score +} + +function localShapeGeometryMatch(problem: ProblemEntry, query: KnowledgeMatchQuery): GeometryMatchResult | null { + const queryAnchors = uniqueValidPoints([ + query.playedMove, + ...(query.candidateMoves ?? []).slice(0, 6), + ...(query.principalVariation ?? []).slice(0, 4), + ...(query.localWindows ?? []).map((window) => window.anchor) + ], query.boardSize) + const problemAnchors = uniqueValidPoints(problem.correctMoves.slice(0, 2).map((move) => move.move), query.boardSize) + if (queryAnchors.length === 0 || problemAnchors.length === 0) return null + + let best: GeometryMatchResult | null = null + for (const queryAnchor of queryAnchors) { + const queryStones = queryStonesForAnchor(query, queryAnchor) + const queryProfile = anchorLibertyProfile(queryStones, queryAnchor, query.boardSize) + if (!queryProfile) continue + const queryRelatives = stonesNearAnchor(queryStones, queryAnchor, query.boardSize) + if (queryRelatives.length < 3) continue + const queryKeys = new Set(queryRelatives.map((stone) => stoneKey(stone, (dx, dy) => [dx, dy], false))) + + for (const problemAnchor of problemAnchors) { + const problemProfile = anchorLibertyProfile(problem.initialStones, problemAnchor, query.boardSize) + if (!problemProfile) continue + const problemRelatives = stonesNearAnchor(problem.initialStones, problemAnchor, query.boardSize) + if (problemRelatives.length < 3) continue + + for (const transform of LOCAL_SHAPE_TRANSFORMS) { + for (const swapColors of [false, true]) { + let matched = 0 + const transformedKeys = new Set(problemRelatives.map((stone) => stoneKey(stone, transform.apply, swapColors))) + for (const key of transformedKeys) { + if (queryKeys.has(key)) { + matched += 1 + } + } + const expected = transformedKeys.size + const rawRatio = expected > 0 ? matched / expected : 0 + const libertyScore = anchorProfileScore(queryProfile, problemProfile, swapColors) + const profileRatio = Math.min(1, libertyScore / 12) + const ratio = (swapColors ? 0.92 : 1) * (rawRatio * 0.76 + profileRatio * 0.24) + if (matched < 3 || ratio < 0.55 || libertyScore < 3) continue + + const score = Math.round(ratio * 24) + Math.min(matched, 8) + libertyScore + const candidate: GeometryMatchResult = { + score, + ratio, + matched, + expected, + libertyScore, + transform: transform.name, + colorMode: swapColors ? 'color-swapped' : 'same-color', + queryAnchor, + problemAnchor + } + if (!best || candidate.score > best.score || (candidate.score === best.score && candidate.ratio > best.ratio)) { + best = candidate + } + } + } + } + } + + return best +} + +function confidence(score: number, exactish = false): KnowledgeMatchConfidence { + if (exactish && score >= 28) return 'exact' + if (score >= 21) return 'strong' + if (score >= 12) return 'partial' + return 'weak' +} + +function capConfidence(value: KnowledgeMatchConfidence, max: KnowledgeMatchConfidence): KnowledgeMatchConfidence { + return CONFIDENCE_RANK[value] > CONFIDENCE_RANK[max] ? max : value +} + +function sortMatchScore(match: KnowledgeMatch): number { + const intentBonus = match.reason.some((reason) => reason.startsWith('explicit-intent:')) ? 8 : 0 + const exactEvidenceBonus = match.reason.some((reason) => reason.startsWith('answer-overlap') || reason.startsWith('sequence-overlap')) ? 4 : 0 + const geometryBonus = match.reason.some((reason) => reason.startsWith('geometry:')) ? 6 : 0 + return CONFIDENCE_RANK[match.confidence] * 1000 + match.score + intentBonus + exactEvidenceBonus + geometryBonus +} + +function applicabilityFor(confidenceValue: KnowledgeMatchConfidence, type: KnowledgeMatchType): string { + if (confidenceValue === 'exact') return '本局局部手顺、候选点和区域都高度一致,可以作为同型讲解。' + if (confidenceValue === 'strong') return '本局棋形和 KataGo 候选点相近,可以作为强相关型讲解,但仍要看全局厚薄。' + if (confidenceValue === 'partial') return `本局只是像这个${type === 'joseki' ? '定式' : '棋形'},老师应说“像这个型”,不能硬套结论。` + return '弱相关,只适合作为备用训练建议,不应进入主讲。' +} + +function problemSummary(problem: ProblemEntry, problemType: RecommendedProblem['problemType']): RecommendedProblem { + const correct = problem.correctMoves[0] + const teaching = problemType === 'life_death' + ? (problem as LifeDeathProblem).teaching.firstFeeling + : (problem as TesujiProblem).teaching.firstHint + return { + id: problem.id, + title: problem.title, + problemType, + difficulty: problem.difficulty, + objective: problem.objective, + firstHint: teaching, + answerSummary: correct ? `${correct.move}: ${correct.explanation ?? '第一手占急所。'}` : '先找急所。', + tags: problem.tags + } +} + +function relatedProblemsForTags( + library: KnowledgeTrainingLibrary, + tags: string[], + limit = 3 +): RecommendedProblem[] { + const tokenSet = new Set(tags.map((tag) => tag.toLowerCase()).filter((tag) => !BROAD_TRAINING_TAGS.has(tag))) + if (tokenSet.size === 0) { + return [] + } + const candidates: Array<{ problem: ProblemEntry; type: RecommendedProblem['problemType']; score: number }> = [ + ...library.lifeDeathProblems.map((problem) => ({ problem, type: 'life_death' as const, score: addOverlapScore(problem.tags, tokenSet, 2, [], 'tag') })), + ...library.tesujiProblems.map((problem) => ({ problem, type: 'tesuji' as const, score: addOverlapScore(problem.tags, tokenSet, 2, [], 'tag') })) + ] + return candidates + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || a.problem.title.localeCompare(b.problem.title)) + .slice(0, limit) + .map((item) => problemSummary(item.problem, item.type)) +} + +function josekiMatch(line: JosekiLine, query: KnowledgeMatchQuery, features: QueryFeatures, library: KnowledgeTrainingLibrary): KnowledgeMatch | null { + let score = 0 + const reasons: string[] = [] + const explicitJosekiIntent = features.explicitIntentTypes.has('joseki') + const explicitTacticalIntent = features.explicitIntentTypes.has('life_death') || features.explicitIntentTypes.has('tesuji') + if (line.levels.includes(query.studentLevel ?? query.userLevel)) { + score += 2 + reasons.push(`level:${query.studentLevel ?? query.userLevel}`) + } + if (line.phase.includes(features.phase)) { + score += 5 + reasons.push(`phase:${features.phase}`) + } + if (features.region === 'corner') { + score += 5 + reasons.push('region:corner') + } + if (explicitJosekiIntent) { + score += 10 + reasons.push('explicit-intent:joseki') + } else if (features.intentTypes.has('joseki')) { + score += 3 + reasons.push('context-intent:joseki') + } + score += addOverlapScore([...line.tags, line.title, line.family], features.tokens, 4, reasons, 'text') + score += featureOverlap(line.normalizedFeatures, new Set([...features.moveFeatures, ...features.candidateFeatures, ...features.pvFeatures]), 4, reasons, 'shape') + const overlap = sequenceOverlap(line.relativeSequence, features.allPoints, reasons) + score += overlap * (features.phase === 'opening' || explicitJosekiIntent ? 5 : 2) + if ((query.candidateMoves ?? []).includes(line.relativeSequence[1])) { + score += 5 + reasons.push('katago-candidate-prefix') + } + if (query.moveNumber <= 70) { + score += 2 + reasons.push('opening-timing') + } + if (features.phase !== 'opening' && !explicitJosekiIntent) { + score -= explicitTacticalIntent ? 22 : 10 + reasons.push(explicitTacticalIntent ? 'penalty:tactical-query' : 'penalty:non-opening-joseki-context') + } + if (score < 8) return null + const exactish = overlap >= 3 && (features.phase === 'opening' || explicitJosekiIntent) && !explicitTacticalIntent + const rawConfidence = confidence(score, exactish) + const confidenceValue = features.phase !== 'opening' && !explicitJosekiIntent + ? capConfidence(rawConfidence, 'partial') + : rawConfidence + return { + id: line.id, + matchType: 'joseki', + title: line.title, + confidence: confidenceValue, + score, + reason: [...new Set(reasons)].slice(0, 8), + applicability: applicabilityFor(confidenceValue, 'joseki'), + teachingPayload: { + summary: line.katagoEraJudgement, + recognition: `识别为${line.title}相关局部:看角部手顺、挂角/点三三位置和外势方向。`, + correctIdea: line.decisionRules.join(' '), + keyVariations: line.branches.slice(0, 3).map((branch) => `${branch.name}: ${branch.whenToChoose}`), + memoryCue: '定式先问方向,再问先手,最后才背手顺。', + commonMistakes: line.commonMistakes, + drills: line.trainingFocus.map((focus) => `${line.title}专项:${focus}`), + boundary: applicabilityFor(confidenceValue, 'joseki'), + sourceKind: line.sourceKind + }, + relatedProblems: relatedProblemsForTags(library, line.tags, 3) + } +} + +function problemMatch( + problem: ProblemEntry, + type: 'life_death' | 'tesuji', + query: KnowledgeMatchQuery, + features: QueryFeatures +): KnowledgeMatch | null { + let score = 0 + const reasons: string[] = [] + const explicitTypeIntent = features.explicitIntentTypes.has(type) + const contextualTypeIntent = features.intentTypes.has(type) + const explicitJosekiOnly = features.explicitIntentTypes.has('joseki') && !features.explicitIntentTypes.has('life_death') && !features.explicitIntentTypes.has('tesuji') + if (problem.region === features.region) { + score += 5 + reasons.push(`region:${problem.region}`) + } + if ((query.lossScore ?? 0) >= 2 || ['mistake', 'blunder'].includes(String(query.judgement))) { + score += type === 'life_death' ? 4 : 3 + reasons.push('katago-loss') + } + if (explicitTypeIntent) { + score += 12 + reasons.push(`explicit-intent:${type}`) + } else if (contextualTypeIntent) { + score += 5 + reasons.push(`context-intent:${type}`) + } + if (explicitJosekiOnly) { + score -= 8 + reasons.push('penalty:joseki-query') + } + const textValues = [...problem.tags, problem.title, problem.objective] + const specificTextHit = hasSpecificTextHit(textValues, features.explicitTokens) + if (specificTextHit) { + score += 6 + reasons.push(`specific-text:${type}`) + } + score += addOverlapScore(textValues, features.tokens, 4, reasons, 'text') + score += featureOverlap(problem.tags, new Set([...features.moveFeatures, ...features.candidateFeatures, ...features.pvFeatures]), 3, reasons, 'shape') + const answerOverlap = sequenceOverlap(problem.correctMoves.map((move) => move.move), features.allPoints, reasons) + score += answerOverlap * 12 + const geometry = localShapeGeometryMatch(problem, query) + if (geometry) { + score += geometry.score + reasons.push(`geometry:${geometry.transform}:${geometry.colorMode}:${geometry.matched}/${geometry.expected}`) + reasons.push(`liberties:${geometry.libertyScore}`) + if (geometry.ratio >= 0.72) { + score += 6 + reasons.push('geometry-strong-local-shape') + } + } + const answerPoints = new Set(problem.correctMoves.map((move) => move.move)) + if (query.playedMove && answerPoints.has(query.playedMove)) { + score += 5 + reasons.push('answer-played') + } + if ((query.candidateMoves ?? []).some((move) => answerPoints.has(move))) { + score += 7 + reasons.push('answer-candidate') + } + if ((query.principalVariation ?? []).some((move) => answerPoints.has(move))) { + score += 5 + reasons.push('answer-pv') + } + if (type === 'life_death' && features.moveFeatures.has('eye-shape')) { + score += 5 + reasons.push('eye-shape') + } + if (type === 'tesuji' && (features.moveFeatures.has('contact') || features.moveFeatures.has('jump'))) { + score += 3 + reasons.push('local-tesuji-relation') + } + if (score < 8) return null + const exactish = (answerOverlap >= 1 && specificTextHit && (explicitTypeIntent || score >= 24)) || Boolean(geometry && geometry.ratio >= 0.72 && geometry.matched >= 3) + const confidenceValue = confidence(score, exactish) + const lifeTeaching = type === 'life_death' ? (problem as LifeDeathProblem).teaching : undefined + const tesujiTeaching = type === 'tesuji' ? (problem as TesujiProblem).teaching : undefined + return { + id: problem.id, + matchType: type, + title: problem.title, + confidence: confidenceValue, + score, + reason: [...new Set(reasons)].slice(0, 8), + applicability: applicabilityFor(confidenceValue, type), + teachingPayload: { + summary: problem.objective, + recognition: lifeTeaching?.recognition ?? tesujiTeaching?.recognition ?? '先识别局部形状和双方气数。', + correctIdea: lifeTeaching?.explanation ?? tesujiTeaching?.tesujiIdea ?? '先找急所,再读失败手。', + keyVariations: problem.correctMoves.slice(0, 2).map((move) => `${move.move}: ${move.explanation ?? '正确第一手'}`), + memoryCue: lifeTeaching?.memoryCue ?? tesujiTeaching?.memoryCue ?? '记住急所和次序。', + commonMistakes: problem.failureMoves.slice(0, 2).map((move) => `${move.move}: ${move.why ?? '次序错误'}`), + drills: [`${problem.title}:先看题,不看答案,读清第一手和失败手。`], + boundary: applicabilityFor(confidenceValue, type), + sourceKind: problem.sourceKind + }, + relatedProblems: [problemSummary(problem, type)] + } +} + +export function searchKnowledgeMatchEngine(dataRoot: string, query: KnowledgeMatchQuery): KnowledgeMatch[] { + const library = loadKnowledgeTrainingLibrary(dataRoot) + const features = buildFeatures(query) + const matches: KnowledgeMatch[] = [] + + for (const line of library.josekiLines) { + const match = josekiMatch(line, query, features, library) + if (match) matches.push(match) + } + for (const problem of library.lifeDeathProblems) { + const match = problemMatch(problem, 'life_death', query, features) + if (match) matches.push(match) + } + for (const problem of library.tesujiProblems) { + const match = problemMatch(problem, 'tesuji', query, features) + if (match) matches.push(match) + } + + const patternMatches = searchKnowledgePatterns(loadKnowledgePatternCards(dataRoot), { + userLevel: query.userLevel, + phase: features.phase, + region: features.region as PatternRegion, + boardSize: query.boardSize, + moveNumber: query.moveNumber, + recentMoves: query.recentMoves, + contextTags: query.contextTags, + text: query.text, + playedMove: query.playedMove, + candidateMoves: query.candidateMoves, + principalVariation: query.principalVariation, + lossScore: query.lossScore, + judgement: query.judgement + }).slice(0, 4) + + for (const pattern of patternMatches) { + const matchType: KnowledgeMatchType = pattern.card.category === 'shape' ? 'shape' : pattern.card.category + const confidenceValue: KnowledgeMatchConfidence = pattern.confidence === 'high' ? 'strong' : pattern.confidence === 'medium' ? 'partial' : 'weak' + matches.push({ + id: pattern.card.id, + matchType, + title: pattern.card.title, + confidence: confidenceValue, + score: pattern.score, + reason: pattern.reasons, + applicability: applicabilityFor(confidenceValue, matchType), + teachingPayload: { + summary: pattern.card.teaching.correctIdea, + recognition: pattern.card.teaching.recognition, + correctIdea: pattern.card.teaching.correctIdea, + keyVariations: pattern.card.variations.slice(0, 3).map((variation) => `${variation.name}: ${variation.whenToChoose}`), + memoryCue: pattern.card.teaching.memoryCue, + commonMistakes: [pattern.card.teaching.commonMistake], + drills: [pattern.card.teaching.drill], + boundary: `${applicabilityFor(confidenceValue, matchType)}\n${formatPatternForPrompt(pattern).split('\n').slice(-1)[0] ?? ''}`, + sourceKind: 'common-pattern' + }, + relatedProblems: relatedProblemsForTags(library, pattern.card.tags, 2) + }) + } + + return matches + .sort((a, b) => sortMatchScore(b) - sortMatchScore(a) || b.score - a.score || a.title.localeCompare(b.title)) + .slice(0, query.maxResults ?? 8) +} + +export function recommendedProblemsFromMatches(matches: KnowledgeMatch[], limit = 3): RecommendedProblem[] { + const seen = new Set() + const problems: RecommendedProblem[] = [] + for (const match of matches) { + if (match.confidence === 'weak' || match.matchType === 'joseki') { + continue + } + for (const problem of match.relatedProblems) { + if (!seen.has(problem.id)) { + seen.add(problem.id) + problems.push(problem) + } + if (problems.length >= limit) { + return problems + } + } + } + return problems +} + +export function formatKnowledgeMatchForPrompt(match: KnowledgeMatch): string { + return [ + `匹配类型: ${match.matchType}`, + `名称: ${match.title}`, + `置信度: ${match.confidence}`, + `匹配依据: ${match.reason.join(', ')}`, + `适用边界: ${match.applicability}`, + `识别特征: ${match.teachingPayload.recognition}`, + `正确思路: ${match.teachingPayload.correctIdea}`, + `常见变化: ${match.teachingPayload.keyVariations.join(';')}`, + `记忆法: ${match.teachingPayload.memoryCue}`, + `常见误区: ${match.teachingPayload.commonMistakes.join(';')}`, + `训练建议: ${match.relatedProblems.map((problem) => `${problem.title}(${problem.difficulty})`).join('、') || match.teachingPayload.drills.join(';')}`, + '老师使用边界: exact/strong 可以说“这是某某型”;partial 只能说“像某某型”;weak 不进入主讲。' + ].join('\n') +} diff --git a/src/main/services/knowledge/patterns.ts b/src/main/services/knowledge/patterns.ts new file mode 100644 index 0000000..301b189 --- /dev/null +++ b/src/main/services/knowledge/patterns.ts @@ -0,0 +1,329 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { CoachUserLevel, GameMove } from '@main/lib/types' + +export type PatternCategory = 'joseki' | 'life_death' | 'tesuji' | 'shape' +export type PatternPhase = 'opening' | 'middlegame' | 'endgame' +export type PatternRegion = 'corner' | 'side' | 'center' +export type PatternConfidence = 'low' | 'medium' | 'high' + +export interface KnowledgePatternCard { + id: string + title: string + category: PatternCategory + patternType: string + phase: PatternPhase[] + levels: CoachUserLevel[] + regions: PatternRegion[] + tags: string[] + aliases: string[] + boardSignals: string[] + triggers: { + moveFeatures?: string[] + candidateFeatures?: string[] + pvFeatures?: string[] + contextTags?: string[] + minMoveNumber?: number + maxMoveNumber?: number + minLossScore?: number + judgements?: string[] + } + shape: { + anchor: string + canonicalMoves: string[] + gtpExamples: string[] + symmetry: 'corner' | 'local' + } + variations: Array<{ + name: string + mainLine: string + whenToChoose: string + warning?: string + }> + teaching: { + recognition: string + correctIdea: string + memoryCue: string + commonMistake: string + drill: string + } +} + +export interface PatternSearchContext { + userLevel: CoachUserLevel + phase: PatternPhase + region: PatternRegion + boardSize: number + moveNumber: number + recentMoves: GameMove[] + contextTags?: string[] + text?: string + playedMove?: string + candidateMoves?: string[] + principalVariation?: string[] + lossScore?: number + judgement?: string +} + +export interface PatternSearchMatch { + card: KnowledgePatternCard + score: number + confidence: PatternConfidence + reasons: string[] +} + +let cachedRoot = '' +let cachedPatterns: KnowledgePatternCard[] | null = null + +export function loadKnowledgePatternCards(dataRoot: string): KnowledgePatternCard[] { + if (cachedPatterns && cachedRoot === dataRoot) { + return cachedPatterns + } + const path = join(dataRoot, 'knowledge', 'pattern-cards.json') + if (!existsSync(path)) { + cachedRoot = dataRoot + cachedPatterns = [] + return cachedPatterns + } + try { + cachedPatterns = JSON.parse(readFileSync(path, 'utf8')) as KnowledgePatternCard[] + } catch { + cachedPatterns = [] + } + cachedRoot = dataRoot + return cachedPatterns +} + +function normalizeTokens(values: Array): string[] { + return values + .flatMap((value) => String(value ?? '').split(/[,。!?、;:,.!?;:()\[\]【】\s\/]+/)) + .map((token) => token.trim().toLowerCase()) + .filter(Boolean) +} + +function gtpToPoint(point: string, boardSize: number): { row: number; col: number } | null { + const match = point.trim().toUpperCase().match(/^([A-HJ-Z])(\d{1,2})$/) + if (!match) { + return null + } + const letters = 'ABCDEFGHJKLMNOPQRSTUVWXYZ' + const col = letters.indexOf(match[1]) + const number = Number(match[2]) + if (col < 0 || col >= boardSize || number < 1 || number > boardSize) { + return null + } + return { col, row: boardSize - number } +} + +function addPointFeatures(features: Set, row: number, col: number, boardSize: number): void { + const x = Math.min(col, boardSize - 1 - col) + const y = Math.min(row, boardSize - 1 - row) + const minEdge = Math.min(x, y) + const maxEdge = Math.max(x, y) + + if (x <= 5 && y <= 5) { + features.add('corner') + } else if (minEdge <= 3) { + features.add('side') + } else { + features.add('center') + } + if (minEdge === 0) features.add('first-line') + if (minEdge === 1) features.add('second-line') + if (minEdge === 2) features.add('third-line') + if (minEdge === 3) features.add('fourth-line') + if (x === 3 && y === 3) features.add('4-4') + if (x === 2 && y === 2) features.add('3-3') + if ((x === 2 && y === 3) || (x === 3 && y === 2)) features.add('3-4') + if (minEdge <= 3 && maxEdge >= 4 && maxEdge <= 6) features.add('approach') + if (minEdge <= 2 && maxEdge <= 5) features.add('eye-shape') +} + +function moveFeaturesFromGtp(points: string[] | undefined, boardSize: number): Set { + const features = new Set() + for (const point of points ?? []) { + const parsed = gtpToPoint(point, boardSize) + if (parsed) { + addPointFeatures(features, parsed.row, parsed.col, boardSize) + } + } + return features +} + +function moveFeaturesFromRecord(moves: GameMove[], boardSize: number): Set { + const features = new Set() + for (const move of moves) { + if (move.row === null || move.col === null) { + continue + } + addPointFeatures(features, move.row, move.col, boardSize) + } + if (moves.length >= 2) { + const last = moves[moves.length - 1] + const prev = moves[moves.length - 2] + if (last.row !== null && last.col !== null && prev.row !== null && prev.col !== null) { + const dx = Math.abs(last.col - prev.col) + const dy = Math.abs(last.row - prev.row) + if (dx + dy === 1) features.add('contact') + if ((dx === 1 && dy === 2) || (dx === 2 && dy === 1)) features.add('knight-move') + if ((dx === 0 && dy === 2) || (dx === 2 && dy === 0)) features.add('jump') + } + } + return features +} + +function overlapScore(needles: string[] | undefined, haystack: Set, weight: number, label: string, reasons: string[]): number { + let score = 0 + for (const needle of needles ?? []) { + if (haystack.has(needle.toLowerCase())) { + score += weight + reasons.push(`${label}:${needle}`) + } + } + return score +} + +function textScore(card: KnowledgePatternCard, context: PatternSearchContext, reasons: string[]): number { + const queryTokens = new Set(normalizeTokens([context.text, ...(context.contextTags ?? [])])) + if (queryTokens.size === 0) { + return 0 + } + const cardTokens = normalizeTokens([ + card.title, + card.category, + card.patternType, + ...card.tags, + ...card.aliases, + ...card.boardSignals, + card.teaching.recognition, + card.teaching.correctIdea, + card.teaching.memoryCue + ]) + let score = 0 + for (const token of queryTokens) { + if (cardTokens.some((candidate) => candidate.includes(token) || token.includes(candidate))) { + score += 4 + reasons.push(`text:${token}`) + } + } + return score +} + +function confidenceFromScore(score: number): PatternConfidence { + if (score >= 18) return 'high' + if (score >= 10) return 'medium' + return 'low' +} + +export function searchKnowledgePatterns(cards: KnowledgePatternCard[], context: PatternSearchContext): PatternSearchMatch[] { + const contextTags = new Set(normalizeTokens(context.contextTags ?? [])) + const recentFeatures = moveFeaturesFromRecord(context.recentMoves.slice(-8), context.boardSize) + const playedFeatures = moveFeaturesFromGtp(context.playedMove ? [context.playedMove] : [], context.boardSize) + const candidateFeatures = moveFeaturesFromGtp(context.candidateMoves, context.boardSize) + const pvFeatures = moveFeaturesFromGtp(context.principalVariation, context.boardSize) + const moveFeatures = new Set([...recentFeatures, ...playedFeatures]) + + return cards + .map((card) => { + let score = 0 + const reasons: string[] = [] + + if (card.levels.includes(context.userLevel)) { + score += 2 + reasons.push(`level:${context.userLevel}`) + } + if (card.phase.includes(context.phase)) { + score += 4 + reasons.push(`phase:${context.phase}`) + } + if (card.regions.includes(context.region)) { + score += 5 + reasons.push(`region:${context.region}`) + } + if (context.moveNumber >= (card.triggers.minMoveNumber ?? 0) && context.moveNumber <= (card.triggers.maxMoveNumber ?? Number.POSITIVE_INFINITY)) { + score += card.triggers.minMoveNumber || card.triggers.maxMoveNumber ? 2 : 0 + } + if ((context.lossScore ?? 0) >= (card.triggers.minLossScore ?? Number.POSITIVE_INFINITY)) { + score += 3 + reasons.push(`loss>=${card.triggers.minLossScore}`) + } + if (context.judgement && card.triggers.judgements?.includes(context.judgement)) { + score += 2 + reasons.push(`judgement:${context.judgement}`) + } + + for (const tag of card.triggers.contextTags ?? []) { + const normalized = tag.toLowerCase() + if (contextTags.has(normalized) || [...contextTags].some((item) => item.includes(normalized) || normalized.includes(item))) { + score += 4 + reasons.push(`tag:${tag}`) + } + } + + score += overlapScore(card.triggers.moveFeatures, moveFeatures, 5, 'shape', reasons) + score += overlapScore(card.triggers.candidateFeatures, candidateFeatures, 6, 'candidate', reasons) + score += overlapScore(card.triggers.pvFeatures, pvFeatures, 3, 'pv', reasons) + score += textScore(card, context, reasons) + + if (card.category === 'joseki' && context.phase === 'opening' && context.region === 'corner') { + score += 3 + reasons.push('joseki-opening-corner') + } + if (card.category === 'life_death' && (context.lossScore ?? 0) >= 2 && ['corner', 'side'].includes(context.region)) { + score += 3 + reasons.push('life-death-risk') + } + + return { + card, + score, + confidence: confidenceFromScore(score), + reasons: [...new Set(reasons)].slice(0, 8) + } + }) + .filter((match) => match.score >= 8) + .sort((a, b) => b.score - a.score || a.card.title.localeCompare(b.card.title)) +} + +export function formatPatternForPrompt(match: PatternSearchMatch): string { + const { card } = match + const variations = card.variations + .slice(0, 3) + .map((variation, index) => [ + `${index + 1}. ${variation.name}`, + ` 主线: ${variation.mainLine}`, + ` 选择条件: ${variation.whenToChoose}`, + variation.warning ? ` 注意: ${variation.warning}` : '' + ].filter(Boolean).join('\n')) + .join('\n') + + return [ + `匹配置信度: ${match.confidence}`, + `匹配依据: ${match.reasons.join(', ')}`, + `棋形识别: ${card.teaching.recognition}`, + `正确思路: ${card.teaching.correctIdea}`, + `常见变化:\n${variations}`, + `记忆法: ${card.teaching.memoryCue}`, + `常见误区: ${card.teaching.commonMistake}`, + `训练题: ${card.teaching.drill}`, + '老师使用边界: 只有在棋形和手顺确实相近时才说“这是某定式/死活型”;匹配不完整时要说“这像这个型”,不要硬套。' + ].join('\n') +} + +export function patternToSearchText(card: KnowledgePatternCard): string { + return [ + card.title, + card.category, + card.patternType, + ...card.tags, + ...card.aliases, + ...card.boardSignals, + ...card.shape.canonicalMoves, + card.teaching.recognition, + card.teaching.correctIdea, + card.teaching.memoryCue, + card.teaching.commonMistake, + card.teaching.drill, + ...card.variations.flatMap((variation) => [variation.name, variation.mainLine, variation.whenToChoose, variation.warning ?? '']) + ].join(' ') +} diff --git a/src/main/services/knowledge/schema.ts b/src/main/services/knowledge/schema.ts new file mode 100644 index 0000000..e1fdd98 --- /dev/null +++ b/src/main/services/knowledge/schema.ts @@ -0,0 +1,41 @@ +export type KnowledgeCardKind = + | 'concept' + | 'error_type' + | 'position_pattern' + | 'training' + | 'review_method' + | 'joseki' + | 'life_death' + | 'tesuji_pattern' + | 'shape_pattern' +export type GamePhase = 'opening' | 'middlegame' | 'endgame' + +export interface KnowledgeCard { + id: string + title: string + kind: KnowledgeCardKind + phase: GamePhase[] + errorTypes: string[] + tags: string[] + katagoSignals: string[] + boardSignals: string[] + summary: string + coachShort: string + coachLong: string + drill: string + related: string[] +} + +export interface KnowledgeSearchQuery { + text?: string + phase?: GamePhase + errorTypes?: string[] + tags?: string[] + limit?: number +} + +export interface KnowledgeSearchResult { + card: KnowledgeCard + score: number + reasons: string[] +} diff --git a/src/main/services/knowledge/searchLocal.ts b/src/main/services/knowledge/searchLocal.ts new file mode 100644 index 0000000..f6d5e2e --- /dev/null +++ b/src/main/services/knowledge/searchLocal.ts @@ -0,0 +1,127 @@ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { KnowledgeCard, KnowledgeSearchQuery, KnowledgeSearchResult } from './schema' +import { loadKnowledgePatternCards, type KnowledgePatternCard } from './patterns' + +let cache: KnowledgeCard[] | null = null + +function knowledgeRoot(): string { + const candidates = [ + join(process.cwd(), 'data'), + process.resourcesPath ? join(process.resourcesPath, 'data') : '' + ].filter(Boolean) + return candidates.find((candidate) => existsSync(join(candidate, 'knowledge', 'p0-cards.json'))) ?? candidates[0] +} + +function knowledgePath(): string { + return join(knowledgeRoot(), 'knowledge', 'p0-cards.json') +} + +function patternKind(card: KnowledgePatternCard): KnowledgeCard['kind'] { + if (card.category === 'joseki') return 'joseki' + if (card.category === 'life_death') return 'life_death' + if (card.category === 'tesuji') return 'tesuji_pattern' + return 'shape_pattern' +} + +function patternToKnowledgeCard(card: KnowledgePatternCard): KnowledgeCard { + return { + id: card.id, + title: card.title, + kind: patternKind(card), + phase: card.phase, + errorTypes: card.category === 'joseki' ? ['direction'] : card.category === 'life_death' ? ['life-death', 'reading'] : ['shape', 'reading'], + tags: [...new Set([...card.tags, ...card.aliases, card.patternType])], + katagoSignals: card.triggers.candidateFeatures ?? [], + boardSignals: [...new Set([...card.boardSignals, ...card.shape.canonicalMoves])], + summary: card.teaching.recognition, + coachShort: card.teaching.correctIdea, + coachLong: [ + `记忆法: ${card.teaching.memoryCue}`, + `常见误区: ${card.teaching.commonMistake}`, + `变化: ${card.variations.map((variation) => `${variation.name} - ${variation.whenToChoose}`).join(';')}` + ].join('\n'), + drill: card.teaching.drill, + related: [] + } +} + +export async function loadKnowledgeCards(): Promise { + if (cache) { + return cache + } + const text = await readFile(knowledgePath(), 'utf8') + const cards = JSON.parse(text) as KnowledgeCard[] + const patternCards = loadKnowledgePatternCards(knowledgeRoot()).map(patternToKnowledgeCard) + cache = [...cards, ...patternCards] + return cache +} + +function normalize(text: string): string[] { + return text + .toLowerCase() + .replace(/[,。!?、;:,.!?;:()()\[\]【】]/g, ' ') + .split(/\s+/) + .map((item) => item.trim()) + .filter(Boolean) +} + +function cardText(card: KnowledgeCard): string { + return [card.title, card.kind, card.summary, card.coachShort, card.coachLong, card.drill, ...card.errorTypes, ...card.tags, ...card.katagoSignals, ...card.boardSignals].join(' ') +} + +export async function searchKnowledgeCards(query: KnowledgeSearchQuery): Promise { + const cards = await loadKnowledgeCards() + const terms = normalize(query.text ?? '') + const wantedErrors = new Set((query.errorTypes ?? []).map((item) => item.toLowerCase())) + const wantedTags = new Set((query.tags ?? []).map((item) => item.toLowerCase())) + + return cards + .map((card) => { + let score = 0 + const reasons: string[] = [] + const text = cardText(card).toLowerCase() + for (const term of terms) { + if (text.includes(term)) { + score += 2 + reasons.push(`match:${term}`) + } + } + if (query.phase && card.phase.includes(query.phase)) { + score += 2 + reasons.push(`phase:${query.phase}`) + } + for (const errorType of card.errorTypes) { + if (wantedErrors.has(errorType.toLowerCase())) { + score += 5 + reasons.push(`error:${errorType}`) + } + } + for (const tag of card.tags) { + if (wantedTags.has(tag.toLowerCase())) { + score += 3 + reasons.push(`tag:${tag}`) + } + } + if (score === 0 && terms.length === 0 && wantedErrors.size === 0 && wantedTags.size === 0) { + score = 1 + reasons.push('default') + } + return { card, score, reasons } + }) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score || a.card.title.localeCompare(b.card.title)) + .slice(0, query.limit ?? 4) +} + +export function formatKnowledgeCardsForPrompt(results: KnowledgeSearchResult[]): string { + return results.map(({ card }, index) => [ + `#${index + 1} ${card.title}`, + `类型: ${card.kind}`, + `错误类型: ${card.errorTypes.join(', ') || '通用'}`, + `摘要: ${card.summary}`, + `老师短讲: ${card.coachShort}`, + `训练建议: ${card.drill}` + ].join('\n')).join('\n\n') +} diff --git a/src/main/services/knowledge/training.ts b/src/main/services/knowledge/training.ts new file mode 100644 index 0000000..7032ec3 --- /dev/null +++ b/src/main/services/knowledge/training.ts @@ -0,0 +1,250 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import type { CoachUserLevel, KnowledgeSourceKind, StoneColor } from '@main/lib/types' + +export type TrainingPhase = 'opening' | 'middlegame' | 'endgame' +export type TrainingRegion = 'corner' | 'side' | 'center' + +export interface JosekiLine { + id: string + title: string + family: string + phase: TrainingPhase[] + levels: CoachUserLevel[] + sourceKind: KnowledgeSourceKind + relativeSequence: string[] + normalizedFeatures: string[] + branches: Array<{ + name: string + sequence: string + whenToChoose: string + warning: string + }> + decisionRules: string[] + commonMistakes: string[] + katagoEraJudgement: string + trainingFocus: string[] + patternCardIds: string[] + tags: string[] +} + +export interface TrainingStone { + color: StoneColor + point: string +} + +export interface TrainingMoveExplanation { + move: string + explanation?: string + why?: string +} + +export interface LifeDeathProblem { + id: string + title: string + difficulty: 'basic' | 'standard' | 'advanced' + region: TrainingRegion + toPlay: StoneColor + objective: string + sourceKind: KnowledgeSourceKind + initialStones: TrainingStone[] + correctMoves: TrainingMoveExplanation[] + failureMoves: TrainingMoveExplanation[] + teaching: { + recognition: string + firstFeeling: string + explanation: string + memoryCue: string + failureExplanation: string + } + patternCardIds: string[] + tags: string[] +} + +export interface TesujiProblem { + id: string + title: string + difficulty: 'basic' | 'standard' | 'advanced' + region: TrainingRegion + toPlay: StoneColor + objective: string + sourceKind: KnowledgeSourceKind + initialStones: TrainingStone[] + correctMoves: TrainingMoveExplanation[] + failureMoves: TrainingMoveExplanation[] + teaching: { + recognition: string + tesujiIdea: string + firstHint: string + memoryCue: string + failureExplanation: string + } + patternCardIds: string[] + tags: string[] +} + +export interface KnowledgeTrainingLibrary { + version: 1 + generatedAt: string + sourcePolicy: { + defaultSourceKind: KnowledgeSourceKind + rule: string + allowedKinds: KnowledgeSourceKind[] + } + josekiLines: JosekiLine[] + lifeDeathProblems: LifeDeathProblem[] + tesujiProblems: TesujiProblem[] +} + +export interface KnowledgeTrainingValidationResult { + ok: boolean + counts: { + josekiLines: number + lifeDeathProblems: number + tesujiProblems: number + } + errors: string[] +} + +let cachedRoot = '' +let cachedLibrary: KnowledgeTrainingLibrary | null = null + +export function loadKnowledgeTrainingLibrary(dataRoot: string): KnowledgeTrainingLibrary { + if (cachedLibrary && cachedRoot === dataRoot) { + return cachedLibrary + } + const path = join(dataRoot, 'knowledge', 'training-catalog.json') + if (!existsSync(path)) { + cachedRoot = dataRoot + cachedLibrary = { + version: 1, + generatedAt: '', + sourcePolicy: { + defaultSourceKind: 'common-pattern', + rule: '', + allowedKinds: ['original', 'common-pattern', 'licensed-source'] + }, + josekiLines: [], + lifeDeathProblems: [], + tesujiProblems: [] + } + return cachedLibrary + } + cachedRoot = dataRoot + cachedLibrary = JSON.parse(readFileSync(path, 'utf8')) as KnowledgeTrainingLibrary + return cachedLibrary +} + +function isGtpPoint(point: string): boolean { + const match = point.trim().toUpperCase().match(/^([A-HJ-T])(\d{1,2})$/) + if (!match) { + return false + } + const letters = 'ABCDEFGHJKLMNOPQRST' + const col = letters.indexOf(match[1]) + const row = Number(match[2]) + return col >= 0 && col < 19 && row >= 1 && row <= 19 +} + +function validateSourceKind(kind: unknown, allowed: Set, errors: string[], id: string): void { + if (typeof kind !== 'string' || !allowed.has(kind)) { + errors.push(`${id}: sourceKind must be original, common-pattern, or licensed-source`) + } +} + +function validatePointList(points: string[], errors: string[], id: string, field: string): void { + for (const point of points) { + if (!isGtpPoint(point)) { + errors.push(`${id}: invalid GTP point in ${field}: ${point}`) + } + } +} + +function validateStones(stones: TrainingStone[], errors: string[], id: string): void { + if (!Array.isArray(stones) || stones.length === 0) { + errors.push(`${id}: initialStones must not be empty`) + return + } + for (const stone of stones) { + if (!['B', 'W'].includes(stone.color) || !isGtpPoint(stone.point)) { + errors.push(`${id}: invalid initial stone ${JSON.stringify(stone)}`) + } + } +} + +function validateMoveExplanations(moves: TrainingMoveExplanation[], errors: string[], id: string, field: string): void { + if (!Array.isArray(moves) || moves.length === 0) { + errors.push(`${id}: ${field} must not be empty`) + return + } + for (const move of moves) { + if (!isGtpPoint(move.move)) { + errors.push(`${id}: invalid ${field} move ${move.move}`) + } + } +} + +function validatePatternRefs(ids: string[], knownPatternIds: Set | undefined, errors: string[], id: string): void { + if (!knownPatternIds) { + return + } + for (const patternId of ids) { + if (!knownPatternIds.has(patternId)) { + errors.push(`${id}: unknown patternCardId ${patternId}`) + } + } +} + +export function validateKnowledgeTrainingLibrary( + library: KnowledgeTrainingLibrary, + knownPatternIds?: Set +): KnowledgeTrainingValidationResult { + const errors: string[] = [] + const ids = new Set() + const allowedKinds = new Set(library.sourcePolicy.allowedKinds) + + for (const line of library.josekiLines) { + if (ids.has(line.id)) errors.push(`${line.id}: duplicate id`) + ids.add(line.id) + if (!line.title || !line.family) errors.push(`${line.id}: missing title or family`) + validateSourceKind(line.sourceKind, allowedKinds, errors, line.id) + validatePointList(line.relativeSequence, errors, line.id, 'relativeSequence') + if (!Array.isArray(line.branches) || line.branches.length === 0) errors.push(`${line.id}: branches must not be empty`) + if (!Array.isArray(line.decisionRules) || line.decisionRules.length === 0) errors.push(`${line.id}: decisionRules must not be empty`) + validatePatternRefs(line.patternCardIds, knownPatternIds, errors, line.id) + } + + for (const problem of library.lifeDeathProblems) { + if (ids.has(problem.id)) errors.push(`${problem.id}: duplicate id`) + ids.add(problem.id) + if (!problem.title || !problem.objective) errors.push(`${problem.id}: missing title or objective`) + validateSourceKind(problem.sourceKind, allowedKinds, errors, problem.id) + validateStones(problem.initialStones, errors, problem.id) + validateMoveExplanations(problem.correctMoves, errors, problem.id, 'correctMoves') + validateMoveExplanations(problem.failureMoves, errors, problem.id, 'failureMoves') + if (!problem.teaching?.recognition || !problem.teaching?.failureExplanation) errors.push(`${problem.id}: missing teaching guidance`) + validatePatternRefs(problem.patternCardIds, knownPatternIds, errors, problem.id) + } + + for (const problem of library.tesujiProblems) { + if (ids.has(problem.id)) errors.push(`${problem.id}: duplicate id`) + ids.add(problem.id) + if (!problem.title || !problem.objective) errors.push(`${problem.id}: missing title or objective`) + validateSourceKind(problem.sourceKind, allowedKinds, errors, problem.id) + validateStones(problem.initialStones, errors, problem.id) + validateMoveExplanations(problem.correctMoves, errors, problem.id, 'correctMoves') + validateMoveExplanations(problem.failureMoves, errors, problem.id, 'failureMoves') + if (!problem.teaching?.recognition || !problem.teaching?.failureExplanation) errors.push(`${problem.id}: missing teaching guidance`) + validatePatternRefs(problem.patternCardIds, knownPatternIds, errors, problem.id) + } + + return { + ok: errors.length === 0, + counts: { + josekiLines: library.josekiLines.length, + lifeDeathProblems: library.lifeDeathProblems.length, + tesujiProblems: library.tesujiProblems.length + }, + errors + } +} diff --git a/src/main/services/library/gameIdentity.ts b/src/main/services/library/gameIdentity.ts new file mode 100644 index 0000000..956a648 --- /dev/null +++ b/src/main/services/library/gameIdentity.ts @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto' + +export interface GameIdentityInput { + sgfText?: string + source?: string + sourceGameId?: string + blackName?: string + whiteName?: string + date?: string + result?: string + moveCount?: number +} + +function normalize(value: string | undefined): string { + return (value ?? '').trim().toLowerCase().replace(/\s+/g, ' ') +} + +export function sgfHash(sgfText: string): string { + return createHash('sha256').update(sgfText.replace(/\r\n/g, '\n').trim(), 'utf8').digest('hex') +} + +export function makeGameDedupeKey(input: GameIdentityInput): string { + if (input.source && input.sourceGameId) { + return `${normalize(input.source)}:${normalize(input.sourceGameId)}` + } + if (input.sgfText) { + return `sgf:${sgfHash(input.sgfText)}` + } + return [ + 'fuzzy', + normalize(input.blackName), + normalize(input.whiteName), + normalize(input.date), + normalize(input.result), + String(input.moveCount ?? '') + ].join(':') +} + +export function isLikelySameGame(a: GameIdentityInput, b: GameIdentityInput): boolean { + if (a.source && b.source && a.source === b.source && a.sourceGameId && a.sourceGameId === b.sourceGameId) { + return true + } + if (a.sgfText && b.sgfText && sgfHash(a.sgfText) === sgfHash(b.sgfText)) { + return true + } + const samePlayers = normalize(a.blackName) === normalize(b.blackName) && normalize(a.whiteName) === normalize(b.whiteName) + const sameMeta = normalize(a.date) === normalize(b.date) && normalize(a.result) === normalize(b.result) + const sameMoves = (a.moveCount ?? -1) === (b.moveCount ?? -2) + return samePlayers && sameMeta && sameMoves +} diff --git a/src/main/services/library/studentBinding.ts b/src/main/services/library/studentBinding.ts new file mode 100644 index 0000000..fece280 --- /dev/null +++ b/src/main/services/library/studentBinding.ts @@ -0,0 +1,105 @@ +import type { StudentProfile } from '../profile/studentProfile' +import { + attachGameToStudent, + listStudents, + resolveStudentByFoxNickname, + upsertManualStudent, + upsertStudentAlias +} from '../profile/studentProfile' + +export interface GamePlayerNames { + blackName?: string + whiteName?: string + source?: 'fox' | 'sgf' | string + foxNickname?: string +} + +export interface StudentBindingSuggestion { + student: StudentProfile + confidence: 'high' | 'medium' | 'low' + reason: string + color?: 'B' | 'W' +} + +function normalize(value: string | undefined): string { + return (value ?? '').trim().toLowerCase() +} + +function colorForName(name: string, game: GamePlayerNames): 'B' | 'W' | undefined { + const wanted = normalize(name) + if (wanted && wanted === normalize(game.blackName)) return 'B' + if (wanted && wanted === normalize(game.whiteName)) return 'W' + return undefined +} + +export async function suggestStudentBindings(game: GamePlayerNames): Promise { + const students = await listStudents() + const suggestions: StudentBindingSuggestion[] = [] + const candidates = [game.foxNickname, game.blackName, game.whiteName].filter(Boolean) as string[] + const candidateSet = new Set(candidates.map(normalize)) + + for (const student of students) { + const names = [student.primaryFoxNickname, student.displayName, ...student.aliases].filter(Boolean) as string[] + const matched = names.find((name) => candidateSet.has(normalize(name))) + if (!matched) continue + const color = colorForName(matched, game) + const isFox = normalize(student.primaryFoxNickname) === normalize(game.foxNickname) + suggestions.push({ + student, + confidence: isFox ? 'high' : color ? 'medium' : 'low', + reason: isFox ? '野狐昵称精确匹配' : color ? `棋手名匹配${color === 'B' ? '黑方' : '白方'}` : '别名匹配', + color + }) + } + + return suggestions.sort((a, b) => { + const score = { high: 3, medium: 2, low: 1 } + return score[b.confidence] - score[a.confidence] + }) +} + +export async function bindFoxGamesToStudent(input: { + foxNickname: string + gameIds: string[] + aliases?: string[] +}): Promise { + let student = await resolveStudentByFoxNickname(input.foxNickname) + for (const alias of input.aliases ?? []) { + student = await upsertStudentAlias(student.id, alias) + } + for (const gameId of input.gameIds) { + student = await attachGameToStudent(gameId, student.id) + } + return student +} + +export async function bindSgfGameToStudent(input: { + gameId: string + studentId?: string + createDisplayName?: string + aliasFromPlayerName?: string +}): Promise { + let student: StudentProfile | null = null + if (input.studentId) { + const all = await listStudents() + student = all.find((item) => item.id === input.studentId) ?? null + if (!student) throw new Error(`找不到学生画像: ${input.studentId}`) + } else if (input.createDisplayName?.trim()) { + student = await upsertManualStudent(input.createDisplayName.trim()) + } + if (!student) return null + if (input.aliasFromPlayerName?.trim()) { + student = await upsertStudentAlias(student.id, input.aliasFromPlayerName.trim()) + } + return attachGameToStudent(input.gameId, student.id) +} + +export function selectStudentColor(game: GamePlayerNames, student: StudentProfile | null): 'B' | 'W' | null { + if (!student) return null + const names = [student.primaryFoxNickname, student.displayName, ...student.aliases].filter(Boolean) as string[] + for (const name of names) { + const color = colorForName(name, game) + if (color) return color + } + return null +} diff --git a/src/main/services/llm.ts b/src/main/services/llm.ts index cf744d8..cb932df 100644 --- a/src/main/services/llm.ts +++ b/src/main/services/llm.ts @@ -1,178 +1,29 @@ -import type { AppSettings, LlmSettingsTestRequest, LlmSettingsTestResult } from '@main/lib/types' +import type { AppSettings, LlmModelsListRequest, LlmModelsListResult, LlmSettingsTestRequest, LlmSettingsTestResult } from '@main/lib/types' import { getSettings } from '@main/lib/store' -import { - completionTokenCount, - extractText, - finishReason, - formatUsage, - hasToolCall, - responseShapeDiagnostics, - type ChatResponse -} from './llmResponse' +import { listOpenAICompatibleModels, postOpenAICompatibleChat, probeOpenAICompatibleProvider, streamOpenAICompatibleChat } from './llm/openaiCompatibleProvider' +import type { ChatMessage, ProviderSettings } from './llm/provider' -const tinyPng = - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/luzK4wAAAABJRU5ErkJggg==' +type LlmDeltaHandler = (delta: string) => void -interface ChatBody { - model: string - messages: unknown[] - temperature?: number - max_completion_tokens?: number - max_tokens?: number - reasoning_effort?: 'low' - modalities?: ['text'] -} - -interface RequestVariant { - label: string - body: ChatBody -} - -function isReasoningModel(model: string): boolean { - return /(^|[-_.:/])gpt-5($|[-_.:/])|^o\d|[-_.:/]o\d|reason|r1/i.test(model) -} - -function shouldRetryEmpty(json: ChatResponse, budget: number): boolean { - const reason = finishReason(json).toLowerCase() - if (/length|max.?tokens/.test(reason)) { - return true - } - if (reason === 'stop' || reason === 'content_filter' || hasToolCall(json)) { - return false - } - const used = completionTokenCount(json.usage) - return used !== null && used >= Math.floor(budget * 0.9) -} - -function shouldTryNextVariantAfterEmpty(json: ChatResponse): boolean { - const reason = finishReason(json).toLowerCase() - if (reason === 'content_filter' || hasToolCall(json)) { - return false - } - const used = completionTokenCount(json.usage) - return used !== null && used > 0 -} - -function emptyResponseError(json: ChatResponse, model: string): Error { - const choice = json.choices?.[0] - const diagnostics = [ - `finish_reason=${finishReason(json)}`, - formatUsage(json.usage), - responseShapeDiagnostics(json) - ] - if (choice?.message?.refusal) { - diagnostics.push(`refusal=${choice.message.refusal.slice(0, 120)}`) - } - if (choice?.message?.reasoning_content && !choice.message.content) { - diagnostics.push('仅返回了 reasoning_content,没有最终讲解文本') - } - if (hasToolCall(json)) { - diagnostics.push('模型返回了 tool_calls,但当前接口需要最终自然语言文本') - } - return new Error(`LLM 没有返回文本内容(model=${model},${diagnostics.join(',')})。如果 finish_reason 是 length,说明输出预算被推理过程耗尽。`) -} - -function requestVariants(model: string, messages: unknown[], maxTokens: number): RequestVariant[] { - const base = { model, messages } - const reasoning = isReasoningModel(model) - const variants: RequestVariant[] = reasoning - ? [ - { label: 'max_completion_tokens+reasoning_effort', body: { ...base, max_completion_tokens: maxTokens, reasoning_effort: 'low' } }, - { label: 'max_completion_tokens+text_modality', body: { ...base, max_completion_tokens: maxTokens, modalities: ['text'] } }, - { label: 'max_completion_tokens', body: { ...base, max_completion_tokens: maxTokens } }, - { label: 'max_tokens', body: { ...base, max_tokens: maxTokens } } - ] - : [ - { label: 'max_completion_tokens+temperature', body: { ...base, temperature: 0.25, max_completion_tokens: maxTokens } }, - { label: 'max_tokens+temperature', body: { ...base, temperature: 0.25, max_tokens: maxTokens } }, - { label: 'max_tokens', body: { ...base, max_tokens: maxTokens } } - ] - - const seen = new Set() - return variants.filter((variant) => { - const key = JSON.stringify(variant.body) - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) -} - -function retryableBodyError(text: string): boolean { - return /max_completion_tokens|max_tokens|temperature|reasoning_effort|modalities|unsupported|unrecognized|unknown parameter/i.test(text) -} - -function expandedTokenBudget(maxTokens: number): number { - return Math.min(Math.max(maxTokens * 2, maxTokens + 1024), 8192) -} - -async function postChat( - settings: Pick, - messages: unknown[], - maxTokens: number -): Promise { - const endpoint = `${settings.llmBaseUrl.replace(/\/$/, '')}/chat/completions` - const headers = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${settings.llmApiKey}` - } - const budgets = [maxTokens, expandedTokenBudget(maxTokens)].filter((value, index, values) => values.indexOf(value) === index) - let lastRetryableError = '' - let lastEmptyResponse: ChatResponse | null = null - - for (const budget of budgets) { - const variants = requestVariants(settings.llmModel, messages, budget) - for (const [variantIndex, variant] of variants.entries()) { - const response = await fetch(endpoint, { - method: 'POST', - headers, - body: JSON.stringify(variant.body), - signal: AbortSignal.timeout(180_000) - }) - - if (!response.ok) { - const text = await response.text().catch(() => '') - if (response.status === 400 && retryableBodyError(text)) { - lastRetryableError = `${response.status} ${text.slice(0, 240)}` - continue - } - throw new Error(`LLM 请求失败: ${response.status} ${text.slice(0, 240)}`) - } - - const json = (await response.json()) as ChatResponse - const content = extractText(json) - if (content) { - return content - } - lastEmptyResponse = json - if (variantIndex < variants.length - 1 && shouldTryNextVariantAfterEmpty(json)) { - continue - } - if (budget < budgets[budgets.length - 1] && shouldRetryEmpty(json, budget)) { - break - } - throw emptyResponseError(json, settings.llmModel) - } +function requireProviderSettings(settings: AppSettings): ProviderSettings { + if (!settings.llmBaseUrl.trim() || !settings.llmApiKey.trim() || !settings.llmModel.trim()) { + throw new Error('请先配置支持图片输入的 OpenAI-compatible 多模态 LLM 代理。') } - - if (lastEmptyResponse) { - throw emptyResponseError(lastEmptyResponse, settings.llmModel) + return { + llmBaseUrl: settings.llmBaseUrl, + llmApiKey: settings.llmApiKey, + llmModel: settings.llmModel } - throw new Error(`LLM 请求失败: ${lastRetryableError || '请求参数不被当前 OpenAI-compatible 服务接受'}`) } export async function callMultimodalTeacher( settings: AppSettings, systemPrompt: string, textPayload: string, - imageDataUrl: string + imageDataUrl: string, + onDelta?: LlmDeltaHandler ): Promise { - if (!settings.llmBaseUrl.trim() || !settings.llmApiKey.trim() || !settings.llmModel.trim()) { - throw new Error('请先配置多模态 LLM API') - } - - return postChat(settings, [ + const messages: ChatMessage[] = [ { role: 'system', content: systemPrompt @@ -184,19 +35,20 @@ export async function callMultimodalTeacher( { type: 'image_url', image_url: { url: imageDataUrl } } ] } - ], 4096) + ] + const providerSettings = requireProviderSettings(settings) + return onDelta + ? streamOpenAICompatibleChat(providerSettings, messages, 4096, onDelta) + : postOpenAICompatibleChat(providerSettings, messages, 4096) } export async function callTeacherText( settings: AppSettings, systemPrompt: string, - textPayload: string + textPayload: string, + onDelta?: LlmDeltaHandler ): Promise { - if (!settings.llmBaseUrl.trim() || !settings.llmApiKey.trim() || !settings.llmModel.trim()) { - throw new Error('请先配置多模态 LLM API') - } - - return postChat(settings, [ + const messages: ChatMessage[] = [ { role: 'system', content: systemPrompt @@ -205,30 +57,45 @@ export async function callTeacherText( role: 'user', content: textPayload } - ], 4096) + ] + const providerSettings = requireProviderSettings(settings) + return onDelta + ? streamOpenAICompatibleChat(providerSettings, messages, 4096, onDelta) + : postOpenAICompatibleChat(providerSettings, messages, 4096) } export async function testLlmSettings(payload: LlmSettingsTestRequest): Promise { + const saved = getSettings() + const settings = { + llmBaseUrl: payload.llmBaseUrl.trim() || saved.llmBaseUrl, + llmApiKey: payload.llmApiKey.trim() || saved.llmApiKey, + llmModel: payload.llmModel.trim() || saved.llmModel + } + const result = await probeOpenAICompatibleProvider(settings) + return { + ok: result.ok, + message: result.technicalDetail ? `${result.message} ${result.technicalDetail}` : result.message + } +} + +export async function listLlmModels(payload: LlmModelsListRequest): Promise { + const saved = getSettings() + const settings = { + llmBaseUrl: payload.llmBaseUrl.trim() || saved.llmBaseUrl, + llmApiKey: payload.llmApiKey.trim() || saved.llmApiKey + } try { - const saved = getSettings() - const text = await postChat({ - llmBaseUrl: payload.llmBaseUrl.trim() || saved.llmBaseUrl, - llmApiKey: payload.llmApiKey.trim() || saved.llmApiKey, - llmModel: payload.llmModel.trim() || saved.llmModel - }, [ - { - role: 'user', - content: [ - { type: 'text', text: '请只回答 OK,确认你能读取图片输入。' }, - { type: 'image_url', image_url: { url: tinyPng } } - ] - } - ], 512) + const models = await listOpenAICompatibleModels(settings) return { - ok: /ok/i.test(text), - message: /ok/i.test(text) ? '多模态模型连接成功。' : `模型有返回,但未按图片测试预期回答: ${text}` + ok: true, + models, + message: models.length ? `已刷新 ${models.length} 个模型。` : '代理可访问,但没有返回模型列表。' } } catch (error) { - return { ok: false, message: String(error) } + return { + ok: false, + models: [], + message: String(error) + } } } diff --git a/src/main/services/llm/openaiCompatibleProvider.ts b/src/main/services/llm/openaiCompatibleProvider.ts new file mode 100644 index 0000000..e299407 --- /dev/null +++ b/src/main/services/llm/openaiCompatibleProvider.ts @@ -0,0 +1,482 @@ +import { + completionTokenCount, + extractText, + finishReason, + formatUsage, + hasToolCall, + responseShapeDiagnostics, + type ChatChoice, + type ChatResponse +} from '../llmResponse' +import type { ChatDelta, ChatInput, ChatMessage, ChatResult, LlmProvider, ProviderProbeResult, ProviderSettings } from './provider' + +const tinyPng = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAeklEQVR42u3SMQ0AIAwAwcrBAQ7wLwEHTMxtsEFv+vmTi7Fm3rOza6Pz/GsQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABBBBAAAEEEEAAAQQQ8EELinI6hhXFUGQAAAAASUVORK5CYII=' + +function endpoint(settings: ProviderSettings): string { + return `${settings.llmBaseUrl.replace(/\/$/, '')}/chat/completions` +} + +function modelsEndpoint(settings: Pick): string { + return `${settings.llmBaseUrl.replace(/\/$/, '')}/models` +} + +function headers(settings: ProviderSettings): Record { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${settings.llmApiKey}` + } +} + +function modelHeaders(settings: Pick): Record { + const headers: Record = { + Accept: 'application/json' + } + if (settings.llmApiKey.trim()) { + headers.Authorization = `Bearer ${settings.llmApiKey}` + } + return headers +} + +function isReasoningModel(model: string): boolean { + return /(^|[-_.:/])gpt-5($|[-_.:/])|^o\d|[-_.:/]o\d|claude|reason|r1/i.test(model) +} + +function requestBodies(model: string, messages: ChatMessage[], maxTokens: number): Array> { + const base = { model, messages } + const reasoning = isReasoningModel(model) + const variants = reasoning + ? [ + { ...base, max_completion_tokens: maxTokens, reasoning_effort: 'low' }, + { ...base, max_completion_tokens: maxTokens, modalities: ['text'] }, + { ...base, max_completion_tokens: maxTokens }, + { ...base, max_tokens: maxTokens } + ] + : [ + { ...base, temperature: 0.25, max_completion_tokens: maxTokens }, + { ...base, temperature: 0.25, max_tokens: maxTokens }, + { ...base, max_tokens: maxTokens } + ] + const seen = new Set() + return variants.filter((body) => { + const key = JSON.stringify(body) + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +function retryableParameterError(status: number, text: string): boolean { + return status === 400 && /max_completion_tokens|max_tokens|temperature|reasoning_effort|modalities|unsupported|unknown parameter|unrecognized/i.test(text) +} + +function shouldRetryBudgetAfterEmpty(json: ChatResponse, budget: number): boolean { + const reason = finishReason(json).toLowerCase() + if (/length|max.?tokens/.test(reason)) { + return true + } + if (reason === 'stop' || reason === 'content_filter' || hasToolCall(json)) { + return false + } + const used = completionTokenCount(json.usage) + return used !== null && used >= Math.floor(budget * 0.9) +} + +function shouldRetryFinalTextAfterEmpty(json: ChatResponse): boolean { + const reason = finishReason(json).toLowerCase() + if (reason === 'content_filter' || hasToolCall(json)) { + return false + } + const used = completionTokenCount(json.usage) + return used !== null && used > 0 +} + +function messagesWithFinalTextReminder(messages: ChatMessage[]): ChatMessage[] { + return [ + { + role: 'system', + content: [ + '重要:本接口只接收最终可展示文本。', + '必须把答案写入普通 message.content。', + '不要只返回 reasoning_content、tool_calls、空 content 或隐藏推理。' + ].join('\n') + }, + ...messages, + { + role: 'user', + content: [ + '上一次请求没有返回可展示给学生的最终文本。', + '请直接输出最终中文讲解,并确保最终文本出现在普通 content 字段中。', + '不要只返回 reasoning、tool_calls、空 content 或调试信息。', + '如果需要结构化结果,请仍然按系统要求输出完整结果。' + ].join('\n') + } + ] +} + +function emptyResponseError(json: ChatResponse, model: string): Error { + const choice = json.choices?.[0] + const diagnostics = [ + `finish_reason=${finishReason(json)}`, + formatUsage(json.usage), + responseShapeDiagnostics(json) + ] + if (choice?.message?.refusal) { + diagnostics.push(`refusal=${choice.message.refusal.slice(0, 120)}`) + } + if (choice?.message?.reasoning_content && !choice.message.content) { + diagnostics.push('仅返回了 reasoning_content,没有最终讲解文本') + } + if (hasToolCall(json)) { + diagnostics.push('模型返回了 tool_calls,但当前接口需要最终自然语言文本') + } + return new Error(`LLM 没有返回文本内容(model=${model},${diagnostics.join(',')})。如果 finish_reason 是 length,说明输出预算被推理过程耗尽。`) +} + +type ChatAttemptResult = + | { kind: 'text'; text: string } + | { kind: 'empty'; json: ChatResponse } + | { kind: 'error'; error: Error } + +async function attemptOpenAICompatibleChat( + settings: ProviderSettings, + messages: ChatMessage[], + maxTokens = 4096 +): Promise { + let lastError = '' + let lastEmptyResponse: ChatResponse | null = null + const budgets = Array.from(new Set([maxTokens, Math.min(Math.max(maxTokens * 2, maxTokens + 1024), 8192)])) + for (const budget of budgets) { + const bodies = requestBodies(settings.llmModel, messages, budget) + for (const [index, body] of bodies.entries()) { + const response = await fetch(endpoint(settings), { + method: 'POST', + headers: headers(settings), + body: JSON.stringify(body), + signal: AbortSignal.timeout(180_000) + }) + if (!response.ok) { + const text = await response.text().catch(() => '') + if (retryableParameterError(response.status, text)) { + lastError = `${response.status} ${text.slice(0, 240)}` + continue + } + return { kind: 'error', error: new Error(`LLM 请求失败: ${response.status} ${text.slice(0, 240)}`) } + } + const json = (await response.json()) as ChatResponse + const text = extractText(json) + if (text) { + return { kind: 'text', text } + } + lastEmptyResponse = json + lastError = `empty response: finish_reason=${finishReason(json)} ${formatUsage(json.usage)}` + if (budget < budgets[budgets.length - 1] && shouldRetryBudgetAfterEmpty(json, budget)) { + break + } + return { kind: 'empty', json } + } + } + if (lastEmptyResponse) { + return { kind: 'empty', json: lastEmptyResponse } + } + return { kind: 'error', error: new Error(`LLM 没有返回文本内容。${lastError}`) } +} + +function streamDeltaText(json: ChatResponse): string { + const choice = json.choices?.[0] as (ChatChoice & { + delta?: { + content?: unknown + text?: unknown + output_text?: unknown + } + }) | undefined + const delta = choice?.delta + for (const value of [delta?.content, delta?.text, delta?.output_text, choice?.text, choice?.message?.content, json.output_text]) { + if (typeof value === 'string' && value !== '') { + return value + } + } + return '' +} + +async function readStreamText(response: Response, onDelta?: (delta: string) => void): Promise { + if (!response.body) { + return '' + } + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let text = '' + for (;;) { + const { value, done } = await reader.read() + if (done) { + break + } + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() ?? '' + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) { + continue + } + const payload = trimmed.replace(/^data:\s*/, '') + if (!payload || payload === '[DONE]') { + continue + } + try { + const delta = streamDeltaText(JSON.parse(payload) as ChatResponse) + if (delta) { + text += delta + onDelta?.(delta) + } + } catch { + // Ignore malformed SSE keepalive chunks from compatible proxies. + } + } + } + return text.trim() +} + +async function attemptOpenAICompatibleStream( + settings: ProviderSettings, + messages: ChatMessage[], + maxTokens = 4096, + onDelta?: (delta: string) => void +): Promise { + let lastError = '' + const bodies = requestBodies(settings.llmModel, messages, maxTokens) + for (const body of bodies) { + const response = await fetch(endpoint(settings), { + method: 'POST', + headers: headers(settings), + body: JSON.stringify({ ...body, stream: true }), + signal: AbortSignal.timeout(180_000) + }) + if (!response.ok) { + const text = await response.text().catch(() => '') + if (retryableParameterError(response.status, text)) { + lastError = `${response.status} ${text.slice(0, 240)}` + continue + } + return { kind: 'error', error: new Error(`LLM 流式请求失败: ${response.status} ${text.slice(0, 240)}`) } + } + const text = await readStreamText(response, onDelta) + if (text) { + return { kind: 'text', text } + } + lastError = 'stream completed without visible content' + } + return { kind: 'error', error: new Error(`LLM 流式请求没有返回文本内容。${lastError}`) } +} + +export async function streamOpenAICompatibleChat( + settings: ProviderSettings, + messages: ChatMessage[], + maxTokens = 4096, + onDelta?: (delta: string) => void +): Promise { + const streamAttempt = await attemptOpenAICompatibleStream(settings, messages, maxTokens, onDelta) + if (streamAttempt.kind === 'text') { + return streamAttempt.text + } + + const firstAttempt = await attemptOpenAICompatibleChat(settings, messages, maxTokens) + if (firstAttempt.kind === 'text') { + onDelta?.(firstAttempt.text) + return firstAttempt.text + } + if (firstAttempt.kind === 'error') { + throw firstAttempt.error + } + + const emptyJson = firstAttempt.kind === 'empty' ? firstAttempt.json : null + if (emptyJson && shouldRetryFinalTextAfterEmpty(emptyJson)) { + const finalTextMessages = messagesWithFinalTextReminder(messages) + const finalStreamAttempt = await attemptOpenAICompatibleStream(settings, finalTextMessages, maxTokens, onDelta) + if (finalStreamAttempt.kind === 'text') { + return finalStreamAttempt.text + } + const finalTextAttempt = await attemptOpenAICompatibleChat(settings, finalTextMessages, maxTokens) + if (finalTextAttempt.kind === 'text') { + onDelta?.(finalTextAttempt.text) + return finalTextAttempt.text + } + if (finalTextAttempt.kind === 'error') { + throw finalTextAttempt.error + } + throw emptyResponseError(finalTextAttempt.json, settings.llmModel) + } + + if (firstAttempt.kind === 'empty') { + throw emptyResponseError(firstAttempt.json, settings.llmModel) + } + if (streamAttempt.kind === 'error') { + throw streamAttempt.error + } + throw new Error('LLM 流式请求没有返回文本内容。') +} + +export async function postOpenAICompatibleChat( + settings: ProviderSettings, + messages: ChatMessage[], + maxTokens = 4096 +): Promise { + const firstAttempt = await attemptOpenAICompatibleChat(settings, messages, maxTokens) + if (firstAttempt.kind === 'text') { + return firstAttempt.text + } + if (firstAttempt.kind === 'error') { + throw firstAttempt.error + } + + if (shouldRetryFinalTextAfterEmpty(firstAttempt.json)) { + const finalTextMessages = messagesWithFinalTextReminder(messages) + const finalTextAttempt = await attemptOpenAICompatibleChat(settings, finalTextMessages, maxTokens) + if (finalTextAttempt.kind === 'text') { + return finalTextAttempt.text + } + if (finalTextAttempt.kind === 'error') { + const streamAttempt = await attemptOpenAICompatibleStream(settings, finalTextMessages, maxTokens) + if (streamAttempt.kind === 'text') { + return streamAttempt.text + } + throw finalTextAttempt.error + } + const streamAttempt = await attemptOpenAICompatibleStream(settings, finalTextMessages, maxTokens) + if (streamAttempt.kind === 'text') { + return streamAttempt.text + } + throw emptyResponseError(finalTextAttempt.json, settings.llmModel) + } + + throw emptyResponseError(firstAttempt.json, settings.llmModel) +} + +export async function probeOpenAICompatibleProvider(settings: ProviderSettings): Promise { + if (!settings.llmBaseUrl.trim() || !settings.llmApiKey.trim() || !settings.llmModel.trim()) { + return { + ok: false, + message: 'Claude 兼容代理未配置。', + supportsImage: false + } + } + try { + const text = await postOpenAICompatibleChat(settings, [ + { + role: 'system', + content: [ + '你正在执行多模态连接测试。', + '必须在最终可见 content 中只输出 OK 两个字母。', + '不要调用工具,不要输出解释,不要只写隐藏推理。' + ].join('\n') + }, + { + role: 'user', + content: [ + { type: 'text', text: '请读取这张测试图片,并在最终答案中只输出 OK。' }, + { type: 'image_url', image_url: { url: tinyPng } } + ] + } + ], 2048) + return { + ok: /ok/i.test(text), + message: /ok/i.test(text) ? 'Claude 兼容代理连接成功,图片输入可用。' : `代理有返回,但未按预期回答: ${text}`, + supportsImage: /ok/i.test(text) + } + } catch (error) { + return { + ok: false, + message: 'Claude 兼容代理测试失败。', + supportsImage: false, + technicalDetail: String(error) + } + } +} + +export async function listOpenAICompatibleModels(settings: Pick): Promise { + if (!settings.llmBaseUrl.trim()) { + throw new Error('请先填写 LLM Base URL。') + } + const response = await fetch(modelsEndpoint(settings), { + method: 'GET', + headers: modelHeaders(settings), + signal: AbortSignal.timeout(20_000) + }) + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(`模型列表刷新失败: ${response.status} ${text.slice(0, 220)}`) + } + const json = (await response.json()) as { + data?: Array<{ id?: unknown; name?: unknown }> + models?: unknown + } + const rawModels = Array.isArray(json.data) + ? json.data.map((model) => model.id ?? model.name) + : Array.isArray(json.models) + ? json.models + : [] + const models = rawModels + .filter((model): model is string => typeof model === 'string' && model.trim().length > 0) + .map((model) => model.trim()) + return Array.from(new Set(models)).sort((left, right) => left.localeCompare(right, 'en')) +} + +export async function chatOpenAICompatible(input: ChatInput): Promise { + const text = await postOpenAICompatibleChat(input.settings, input.messages, input.maxTokens ?? 4096) + return { text } +} + +export async function* streamChatOpenAICompatible(input: ChatInput): AsyncIterable { + const queue: ChatDelta[] = [] + let wake: (() => void) | null = null + let finished = false + let failure: unknown = null + const notify = (): void => { + wake?.() + wake = null + } + const task = streamOpenAICompatibleChat(input.settings, input.messages, input.maxTokens ?? 4096, (delta) => { + queue.push({ text: delta }) + notify() + }).then( + () => { + queue.push({ text: '', done: true }) + finished = true + notify() + }, + (error) => { + failure = error + finished = true + notify() + } + ) + + while (!finished || queue.length > 0) { + const next = queue.shift() + if (next) { + yield next + continue + } + if (failure) { + throw failure + } + await new Promise((resolve) => { + wake = resolve + }) + } + await task + if (failure) { + throw failure + } +} + +export const openAICompatibleProvider: LlmProvider = { + id: 'openai-compatible', + label: 'OpenAI-compatible multimodal proxy', + probe: probeOpenAICompatibleProvider, + chat: chatOpenAICompatible, + streamChat: streamChatOpenAICompatible +} diff --git a/src/main/services/llm/provider.ts b/src/main/services/llm/provider.ts new file mode 100644 index 0000000..a024d81 --- /dev/null +++ b/src/main/services/llm/provider.ts @@ -0,0 +1,45 @@ +export type ChatContentPart = + | { type: 'text'; text: string } + | { type: 'image_url'; image_url: { url: string } } + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' + content: string | ChatContentPart[] +} + +export interface ProviderSettings { + llmBaseUrl: string + llmApiKey: string + llmModel: string +} + +export interface ChatInput { + settings: ProviderSettings + messages: ChatMessage[] + maxTokens?: number +} + +export interface ChatResult { + text: string + raw?: unknown +} + +export interface ChatDelta { + text: string + done?: boolean +} + +export interface ProviderProbeResult { + ok: boolean + message: string + supportsImage?: boolean + technicalDetail?: string +} + +export interface LlmProvider { + id: string + label: string + probe(settings: ProviderSettings): Promise + chat(input: ChatInput): Promise + streamChat?(input: ChatInput): AsyncIterable +} diff --git a/src/main/services/llmResponse.ts b/src/main/services/llmResponse.ts index 4567b2a..0911d47 100644 --- a/src/main/services/llmResponse.ts +++ b/src/main/services/llmResponse.ts @@ -110,11 +110,18 @@ export function extractText(json: ChatResponse): string { choice?.message?.content_parts, choice?.message?.output_text, choice?.message?.text, + (choice?.message as Record | undefined)?.parsed, + (choice?.message as Record | undefined)?.answer, choice?.text, choice?.content, + (choice as Record | undefined)?.delta, json.output_text, json.message?.content, - json.content + json.content, + (json as Record).result, + (json as Record).response, + (json as Record).reply, + (json as Record).answer ] for (const candidate of directCandidates) { diff --git a/src/main/services/profile/studentProfile.ts b/src/main/services/profile/studentProfile.ts new file mode 100644 index 0000000..d3ee167 --- /dev/null +++ b/src/main/services/profile/studentProfile.ts @@ -0,0 +1 @@ +export * from '../studentProfile' diff --git a/src/main/services/release/readiness.ts b/src/main/services/release/readiness.ts new file mode 100644 index 0000000..bbc2940 --- /dev/null +++ b/src/main/services/release/readiness.ts @@ -0,0 +1,143 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type { ReleaseReadinessFlags, ReleaseReadinessItem, ReleaseReadinessResult, ReleaseReadinessStatus } from '../../lib/types' + +function item(id: string, label: string, status: ReleaseReadinessStatus, detail?: string): ReleaseReadinessItem { + return { id, label, status, detail } +} + +function aggregate(items: ReleaseReadinessItem[]): ReleaseReadinessStatus { + if (items.some((entry) => entry.status === 'fail')) return 'fail' + if (items.some((entry) => entry.status === 'warn')) return 'warn' + if (items.some((entry) => entry.status === 'unknown')) return 'unknown' + return 'pass' +} + +export function inspectReleaseReadiness(projectRoot = process.cwd()): ReleaseReadinessResult { + const requiredFiles = [ + 'package.json', + 'data/knowledge/p0-cards.json', + 'data/katago/manifest.json', + 'scripts/check_katago_assets.mjs', + 'scripts/p0_beta_acceptance.mjs', + 'src/main/services/diagnostics/index.ts', + 'src/main/services/llm/openaiCompatibleProvider.ts', + 'src/main/services/studentProfile.ts', + 'src/main/services/teacherAgent.ts', + 'src/renderer/src/features/board/GoBoardV2.tsx', + 'src/renderer/src/features/teacher/TeacherRunCardPro.tsx' + ] + + const items: ReleaseReadinessItem[] = requiredFiles.map((relativePath) => { + const fullPath = join(projectRoot, relativePath) + return existsSync(fullPath) + ? item(relativePath, relativePath, 'pass') + : item(relativePath, relativePath, 'fail', '缺少 P0 必备文件') + }) + const automationReady = items.every((entry) => entry.status === 'pass') + + const katagoBinaryCandidates = [ + 'data/katago/bin/darwin-arm64/katago', + 'data/katago/bin/darwin-x64/katago', + 'data/katago/bin/win32-x64/katago.exe' + ] + const presentBinaryCount = katagoBinaryCandidates.filter((relativePath) => existsSync(join(projectRoot, relativePath))).length + const allBinariesReady = presentBinaryCount === katagoBinaryCandidates.length + items.push( + allBinariesReady + ? item('katago-binaries', 'KataGo 平台二进制', 'pass', `检测到 ${presentBinaryCount}/${katagoBinaryCandidates.length} 个候选二进制`) + : item('katago-binaries', 'KataGo 平台二进制', 'warn', '源码仓库可不提交二进制,但 release 前必须通过 prepare assets 脚本准备') + ) + + const modelCandidates = [ + 'data/katago/models/default.bin.gz', + 'data/katago/models/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz' + ] + const hasModel = modelCandidates.some((relativePath) => existsSync(join(projectRoot, relativePath))) + items.push( + hasModel + ? item('katago-model', 'KataGo 默认模型', 'pass') + : item('katago-model', 'KataGo 默认模型', 'warn', '源码仓库可不提交模型,但 release 前必须准备默认模型') + ) + + const assetsReady = allBinariesReady && hasModel + const version = '0.2.0-beta.1' + const releaseRoot = join(projectRoot, 'release', version) + const installerCandidates = [ + `release/${version}/GoMentor-${version}-mac-arm64.dmg`, + `release/${version}/GoMentor-${version}-mac-x64.dmg`, + `release/${version}/GoMentor-${version}-win-x64.exe` + ] + const missingInstallers = installerCandidates.filter((relativePath) => !existsSync(join(projectRoot, relativePath))) + const winArm64Installer = existsSync(join(releaseRoot, `GoMentor-${version}-win-arm64.exe`)) + const installersReady = missingInstallers.length === 0 && !winArm64Installer + items.push( + installersReady + ? item('installers-ready', 'P0 beta 安装包', 'pass', 'macOS arm64/x64 与 Windows x64 安装包已存在') + : item( + 'installers-ready', + 'P0 beta 安装包', + 'fail', + [ + missingInstallers.length > 0 ? `缺少: ${missingInstallers.join(', ')}` : '', + winArm64Installer ? '检测到不支持的 Windows ARM64 产物' : '' + ].filter(Boolean).join(';') + ) + ) + + const signingReady = + process.env.GOMENTOR_SIGNING_READY === '1' || + existsSync(join(projectRoot, 'release-evidence', 'signing-ready.json')) + const windowsSmokeReady = + process.env.GOMENTOR_WINDOWS_SMOKE_READY === '1' || + existsSync(join(projectRoot, 'release-evidence', 'windows-smoke-ready.json')) + const visualQaReady = + process.env.GOMENTOR_VISUAL_QA_READY === '1' || + existsSync(join(projectRoot, 'release-evidence', 'visual-qa-ready.json')) + + items.push( + signingReady + ? item('signing-ready', '签名与公证', 'pass', '已检测到签名验收证据') + : item('signing-ready', '签名与公证', 'fail', '公开 beta 前需要 macOS 签名/公证与 Windows 签名证据') + ) + items.push( + windowsSmokeReady + ? item('windows-smoke-ready', 'Windows 真机 smoke', 'pass', '已检测到 Windows 真机验收证据') + : item('windows-smoke-ready', 'Windows 真机 smoke', 'fail', '公开 beta/tag 前必须完成 Windows 11 x64 真机 smoke') + ) + items.push( + visualQaReady + ? item('visual-qa-ready', '视觉 QA 证据', 'pass', '已检测到视觉 QA 证据') + : item('visual-qa-ready', '视觉 QA 证据', 'fail', '公开 beta 前必须完成截图验收') + ) + + const flags: ReleaseReadinessFlags = { + automationReady, + assetsReady, + installersReady, + signingReady, + windowsSmokeReady, + visualQaReady, + publicBetaReady: false + } + flags.publicBetaReady = Object.entries(flags) + .filter(([key]) => key !== 'publicBetaReady') + .every(([, ready]) => ready) + + items.unshift( + flags.publicBetaReady + ? item('public-beta-ready', 'Public Beta 发布状态', 'pass', '所有自动化与人工 gate 均已通过') + : item( + 'public-beta-ready', + 'Public Beta 发布状态', + 'fail', + 'publicBetaReady=false:签名/公证、Windows 真机 smoke、视觉 QA 任一缺失都不能 tag' + ) + ) + + return { + status: aggregate(items), + items, + flags + } +} diff --git a/src/main/services/review.ts b/src/main/services/review.ts index 781c8e8..8430b87 100644 --- a/src/main/services/review.ts +++ b/src/main/services/review.ts @@ -5,6 +5,7 @@ import { findGame, getSettings, reviewsDir } from '@main/lib/store' import type { ReviewArtifact, ReviewRequest, ReviewResult } from '@main/lib/types' import { resolveKataGoRuntime } from './katagoRuntime' import { ensurePythonRuntime } from './pythonRuntime' +import { ensureFoxGameDownloaded } from './fox' interface PythonReviewOutput { markdown_path: string @@ -13,10 +14,11 @@ interface PythonReviewOutput { } export async function runReview(request: ReviewRequest): Promise { - const game = findGame(request.gameId) - if (!game) { + const indexedGame = findGame(request.gameId) + if (!indexedGame) { throw new Error('找不到要复盘的棋谱') } + const game = await ensureFoxGameDownloaded(indexedGame) const settings = getSettings() const runtime = resolveKataGoRuntime(settings) diff --git a/src/main/services/sgf.ts b/src/main/services/sgf.ts index 8a458dc..d952727 100644 --- a/src/main/services/sgf.ts +++ b/src/main/services/sgf.ts @@ -191,6 +191,11 @@ function sanitizeName(input: string): string { return input.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase() } +export function foxSgfPathForGameId(gameId: string): string { + const targetDir = join(libraryDir, 'fox') + return join(targetDir, `${sanitizeName(gameId) || 'fox-game'}.sgf`) +} + export function importSgfFile(filePath: string, source: LibraryGame['source'], sourceLabel: string): LibraryGame { const content = readFileSync(filePath, 'utf8') const hash = createHash('sha1').update(content).digest('hex').slice(0, 12) @@ -218,14 +223,17 @@ export function importSgfFile(filePath: string, source: LibraryGame['source'], s } } -export function saveFoxSgf(content: string, title: string, sourceLabel: string): LibraryGame { +export function saveFoxSgf(content: string, title: string, sourceLabel: string, baseGame?: Partial): LibraryGame { const hash = createHash('sha1').update(content).digest('hex').slice(0, 12) + const id = baseGame?.id || hash const targetDir = join(libraryDir, 'fox') mkdirSync(targetDir, { recursive: true }) - const targetPath = join(targetDir, `${sanitizeName(title) || 'fox-game'}-${hash}.sgf`) + const targetPath = baseGame?.id + ? foxSgfPathForGameId(id) + : join(targetDir, `${sanitizeName(title) || 'fox-game'}-${hash}.sgf`) writeFileSync(targetPath, content, 'utf8') return { - id: hash, + id, title: sgfTitle(content, title), event: extract('EV', content), black: extract('PB', content), @@ -235,7 +243,11 @@ export function saveFoxSgf(content: string, title: string, sourceLabel: string): source: 'fox', sourceLabel, filePath: targetPath, - createdAt: new Date().toISOString() + createdAt: baseGame?.createdAt || new Date().toISOString(), + downloadStatus: 'downloaded', + remoteId: baseGame?.remoteId, + remoteUid: baseGame?.remoteUid, + moveCount: baseGame?.moveCount } } diff --git a/src/main/services/studentProfile.ts b/src/main/services/studentProfile.ts index e6bf497..418875b 100644 --- a/src/main/services/studentProfile.ts +++ b/src/main/services/studentProfile.ts @@ -1,32 +1,239 @@ +import { randomUUID } from 'node:crypto' import { profileStore } from '@main/lib/store' import type { CoachUserLevel, StudentProfile } from '@main/lib/types' +export type { StudentProfile } from '@main/lib/types' + +interface ProfileIndex { + version: 1 + aliasToId: Record + gameStudentMap: Record +} + +const INDEX_KEY = '__profile_index__' + +function normalizeName(value: string): string { + return value.trim().toLowerCase() +} + function profileId(name: string): string { - return (name || 'default-student').trim().toLowerCase().replace(/\s+/g, '-') + const normalized = normalizeName(name) + return normalized ? randomUUID() : 'default-student' +} + +function nowIso(): string { + return new Date().toISOString() +} + +function emptyIndex(): ProfileIndex { + return { version: 1, aliasToId: {}, gameStudentMap: {} } +} + +function getIndex(): ProfileIndex { + return (profileStore.get(INDEX_KEY) as ProfileIndex | undefined) ?? emptyIndex() +} + +function saveIndex(index: ProfileIndex): void { + profileStore.set(INDEX_KEY, index) } -function emptyProfile(name: string): StudentProfile { +function mistakeArrayToStats(commonMistakes: StudentProfile['commonMistakes'] | undefined): Record { + const stats: Record = {} + for (const item of commonMistakes ?? []) { + stats[item.tag] = item.count + } + return stats +} + +function statsToMistakeArray(stats: Record): StudentProfile['commonMistakes'] { + return Object.entries(stats) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 12) +} + +function hydrateProfile(raw: unknown, fallbackName = '默认学生'): StudentProfile | null { + if (!raw || typeof raw !== 'object') { + return null + } + const data = raw as Partial + const name = data.name ?? data.displayName ?? fallbackName + const createdAt = data.createdAt ?? data.updatedAt ?? nowIso() + const weaknessStats = data.weaknessStats ?? mistakeArrayToStats(data.commonMistakes) + const stableId = data.studentId ?? data.id ?? profileId(name) return { - id: profileId(name), - name: name || '默认学生', + id: stableId, + studentId: stableId, + name, + displayName: data.displayName ?? name, + primaryFoxNickname: data.primaryFoxNickname, + aliases: Array.from(new Set([...(data.aliases ?? []), name, data.displayName ?? ''].filter(Boolean))), + createdFrom: data.createdFrom ?? 'legacy', + userLevel: data.userLevel ?? 'intermediate', + gamesReviewed: data.gamesReviewed ?? 0, + weaknessStats, + recentPatterns: data.recentPatterns ?? [], + trainingFocus: data.trainingFocus ?? data.trainingThemes ?? [], + recentGameIds: data.recentGameIds ?? [], + commonMistakes: data.commonMistakes ?? statsToMistakeArray(weaknessStats), + trainingThemes: data.trainingThemes ?? data.trainingFocus ?? [], + josekiWeaknesses: data.josekiWeaknesses ?? [], + lifeDeathWeaknesses: data.lifeDeathWeaknesses ?? [], + tesujiWeaknesses: data.tesujiWeaknesses ?? [], + typicalMoves: data.typicalMoves ?? [], + updatedAt: data.updatedAt ?? createdAt, + createdAt, + lastAnalyzedAt: data.lastAnalyzedAt + } +} + +function saveStudentProfileInternal(profile: StudentProfile): StudentProfile { + const next: StudentProfile = { + ...profile, + id: profile.studentId || profile.id, + studentId: profile.studentId || profile.id, + name: profile.displayName || profile.name, + displayName: profile.displayName || profile.name, + commonMistakes: statsToMistakeArray(profile.weaknessStats), + trainingThemes: Array.from(new Set([...(profile.trainingThemes ?? []), ...(profile.trainingFocus ?? [])])).slice(0, 12), + updatedAt: nowIso() + } + profileStore.set(next.studentId, next) + + const index = getIndex() + for (const alias of [next.name, next.displayName, next.primaryFoxNickname ?? '', ...next.aliases]) { + const normalized = normalizeName(alias) + if (normalized) { + index.aliasToId[normalized] = next.studentId + } + } + saveIndex(index) + return next +} + +function allProfiles(): StudentProfile[] { + return Object.entries(profileStore.store) + .filter(([key]) => key !== INDEX_KEY) + .map(([, value]) => hydrateProfile(value)) + .filter((profile): profile is StudentProfile => Boolean(profile)) +} + +function findByAlias(name: string): StudentProfile | null { + const normalized = normalizeName(name) + if (!normalized) { + return null + } + const index = getIndex() + const indexedId = index.aliasToId[normalized] + if (indexedId) { + const indexed = hydrateProfile(profileStore.get(indexedId), name) + if (indexed) { + return indexed + } + } + return allProfiles().find((profile) => + [profile.name, profile.displayName, profile.primaryFoxNickname ?? '', ...profile.aliases] + .map(normalizeName) + .includes(normalized) + ) ?? null +} + +export function listStudents(): StudentProfile[] { + return allProfiles().sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) +} + +export function resolveStudentByName(displayName: string, createdFrom: StudentProfile['createdFrom'] = 'manual'): StudentProfile { + const name = displayName.trim() || '默认学生' + const existing = findByAlias(name) + if (existing) { + return saveStudentProfileInternal(existing) + } + const timestamp = nowIso() + const id = profileId(name) + return saveStudentProfileInternal({ + id, + studentId: id, + name, + displayName: name, + aliases: [name], + createdFrom, userLevel: 'intermediate', gamesReviewed: 0, + weaknessStats: {}, + recentPatterns: [], + trainingFocus: [], + recentGameIds: [], commonMistakes: [], trainingThemes: [], + josekiWeaknesses: [], + lifeDeathWeaknesses: [], + tesujiWeaknesses: [], typicalMoves: [], - updatedAt: new Date().toISOString() + createdAt: timestamp, + updatedAt: timestamp + }) +} + +export function upsertManualStudent(displayName: string): StudentProfile { + return resolveStudentByName(displayName, 'manual') +} + +export function resolveStudentByFoxNickname(nickname: string): StudentProfile { + const name = nickname.trim() || '默认学生' + const existing = findByAlias(name) + if (existing) { + return saveStudentProfileInternal({ + ...existing, + displayName: existing.displayName || name, + primaryFoxNickname: existing.primaryFoxNickname ?? name, + aliases: Array.from(new Set([...existing.aliases, name])), + createdFrom: existing.createdFrom === 'legacy' ? 'fox' : existing.createdFrom + }) + } + const student = resolveStudentByName(name, 'fox') + return saveStudentProfileInternal({ + ...student, + primaryFoxNickname: name, + aliases: Array.from(new Set([...student.aliases, name])) + }) +} + +export function upsertStudentAlias(studentId: string, alias: string): StudentProfile { + const profile = hydrateProfile(profileStore.get(studentId), alias) + if (!profile) { + throw new Error(`找不到学生画像: ${studentId}`) + } + const normalizedAliases = new Set(profile.aliases.map(normalizeName)) + const nextAlias = alias.trim() + if (nextAlias && !normalizedAliases.has(normalizeName(nextAlias))) { + profile.aliases.push(nextAlias) + } + return saveStudentProfileInternal(profile) +} + +export function attachGameToStudent(gameId: string, studentId: string): StudentProfile { + const profile = hydrateProfile(profileStore.get(studentId)) + if (!profile) { + throw new Error(`找不到学生画像: ${studentId}`) } + const index = getIndex() + index.gameStudentMap[gameId] = profile.studentId + saveIndex(index) + const nextGames = [gameId, ...profile.recentGameIds.filter((id) => id !== gameId)].slice(0, 80) + return saveStudentProfileInternal({ ...profile, recentGameIds: nextGames }) +} + +export function readStudentForGame(gameId: string): StudentProfile | null { + const studentId = getIndex().gameStudentMap[gameId] + return studentId ? hydrateProfile(profileStore.get(studentId)) : null } export function getStudentProfile(name: string): StudentProfile { - const id = profileId(name) - return (profileStore.get(id) as StudentProfile | undefined) ?? emptyProfile(name) + return resolveStudentByName(name || '默认学生') } export function saveStudentProfile(profile: StudentProfile): StudentProfile { - const next = { ...profile, updatedAt: new Date().toISOString() } - profileStore.set(next.id, next) - return next + return saveStudentProfileInternal(profile) } export function updateStudentProfile( @@ -37,28 +244,107 @@ export function updateStudentProfile( mistakeTags?: string[] trainingThemes?: string[] typicalMoves?: StudentProfile['typicalMoves'] + recentPatterns?: string[] + trainingFocus?: string[] + josekiWeaknesses?: string[] + lifeDeathWeaknesses?: string[] + tesujiWeaknesses?: string[] + gameId?: string } ): StudentProfile { const profile = getStudentProfile(name) - const counts = new Map(profile.commonMistakes.map((item) => [item.tag, item.count])) + const weaknessStats = { ...profile.weaknessStats } for (const tag of update.mistakeTags ?? []) { - counts.set(tag, (counts.get(tag) ?? 0) + 1) + weaknessStats[tag] = (weaknessStats[tag] ?? 0) + 1 } - const themes = new Set([...profile.trainingThemes, ...(update.trainingThemes ?? [])]) + const trainingFocus = Array.from(new Set([ + ...(update.trainingFocus ?? []), + ...(update.trainingThemes ?? []), + ...profile.trainingFocus + ])).slice(0, 12) + const recentPatterns = Array.from(new Set([...(update.recentPatterns ?? []), ...profile.recentPatterns])).slice(0, 20) + const josekiWeaknesses = Array.from(new Set([...(update.josekiWeaknesses ?? []), ...(profile.josekiWeaknesses ?? [])])).slice(0, 12) + const lifeDeathWeaknesses = Array.from(new Set([...(update.lifeDeathWeaknesses ?? []), ...(profile.lifeDeathWeaknesses ?? [])])).slice(0, 12) + const tesujiWeaknesses = Array.from(new Set([...(update.tesujiWeaknesses ?? []), ...(profile.tesujiWeaknesses ?? [])])).slice(0, 12) const typicalMoves = [...(update.typicalMoves ?? []), ...profile.typicalMoves] .sort((a, b) => b.lossWinrate - a.lossWinrate) .slice(0, 12) + const recentGameIds = update.gameId + ? [update.gameId, ...profile.recentGameIds.filter((id) => id !== update.gameId)].slice(0, 80) + : profile.recentGameIds - return saveStudentProfile({ + const saved = saveStudentProfileInternal({ ...profile, userLevel: update.userLevel ?? profile.userLevel, gamesReviewed: profile.gamesReviewed + (update.reviewedGames ?? 0), - commonMistakes: [...counts.entries()] - .map(([tag, count]) => ({ tag, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 12), - trainingThemes: [...themes].slice(0, 10), - typicalMoves + weaknessStats, + recentPatterns, + trainingFocus, + josekiWeaknesses, + lifeDeathWeaknesses, + tesujiWeaknesses, + recentGameIds, + typicalMoves, + lastAnalyzedAt: nowIso() }) + if (update.gameId) { + const index = getIndex() + index.gameStudentMap[update.gameId] = saved.studentId + saveIndex(index) + } + return saved +} + +export function updateStudentAfterAnalysis(input: { + studentId: string + gameId?: string + errorTypes: string[] + patterns: string[] + trainingFocus: string[] +}): StudentProfile { + const profile = hydrateProfile(profileStore.get(input.studentId)) + if (!profile) { + throw new Error(`找不到学生画像: ${input.studentId}`) + } + const weaknessStats = { ...profile.weaknessStats } + for (const errorType of input.errorTypes) { + weaknessStats[errorType] = (weaknessStats[errorType] ?? 0) + 1 + } + const saved = saveStudentProfileInternal({ + ...profile, + weaknessStats, + recentPatterns: Array.from(new Set([...input.patterns, ...profile.recentPatterns])).slice(0, 20), + trainingFocus: Array.from(new Set([...input.trainingFocus, ...profile.trainingFocus])).slice(0, 12), + recentGameIds: input.gameId ? [input.gameId, ...profile.recentGameIds.filter((id) => id !== input.gameId)].slice(0, 80) : profile.recentGameIds, + lastAnalyzedAt: nowIso() + }) + if (input.gameId) { + attachGameToStudent(input.gameId, saved.studentId) + } + return saved +} + +export function formatStudentProfileForPrompt(student: StudentProfile | null): string { + if (!student) { + return '当前没有绑定学生画像。请按首次接触学生的方式讲解,不要臆造长期弱点。' + } + const weakness = Object.entries(student.weaknessStats) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([name, count]) => `${name}:${count}`) + .join(', ') || '暂无稳定统计' + return [ + `学生: ${student.displayName}`, + `student_id: ${student.studentId}`, + `野狐昵称: ${student.primaryFoxNickname ?? '未绑定'}`, + `别名: ${student.aliases.join(', ') || '无'}`, + `复盘局数: ${student.gamesReviewed}`, + `常见问题: ${weakness}`, + `定式弱点: ${(student.josekiWeaknesses ?? []).join(';') || '暂无'}`, + `死活弱点: ${(student.lifeDeathWeaknesses ?? []).join(';') || '暂无'}`, + `手筋弱点: ${(student.tesujiWeaknesses ?? []).join(';') || '暂无'}`, + `近期模式: ${student.recentPatterns.join(';') || '暂无'}`, + `训练重点: ${student.trainingFocus.join(';') || '暂无'}` + ].join('\n') } diff --git a/src/main/services/systemProfile.ts b/src/main/services/systemProfile.ts index 6123f2e..917e573 100644 --- a/src/main/services/systemProfile.ts +++ b/src/main/services/systemProfile.ts @@ -121,6 +121,8 @@ export async function applyDetectedDefaults(settings: AppSettings): Promise model === 'gpt-5.5') || + detected.proxyModels.find((model) => model === 'gpt-5.4-mini') || detected.proxyModels.find((model) => model === 'gpt-5-codex-mini') || detected.proxyModels.find((model) => model === 'gpt-5') || detected.proxyModels[0] || diff --git a/src/main/services/teacher/resultSchema.ts b/src/main/services/teacher/resultSchema.ts new file mode 100644 index 0000000..f95864f --- /dev/null +++ b/src/main/services/teacher/resultSchema.ts @@ -0,0 +1,29 @@ +export type TeacherTaskType = 'current-move' | 'full-game' | 'recent-games' | 'freeform' + +export interface TeacherKeyMistake { + moveNumber?: number + color?: 'B' | 'W' + played?: string + recommended?: string + errorType: string + severity: 'inaccuracy' | 'mistake' | 'blunder' + evidence: string + explanation: string +} + +export interface StructuredTeacherResult { + taskType: TeacherTaskType + headline: string + summary: string + keyMistakes: TeacherKeyMistake[] + correctThinking: string[] + drills: string[] + followupQuestions: string[] + markdown: string + knowledgeCardIds: string[] + profileUpdates: { + errorTypes: string[] + patterns: string[] + trainingFocus: string[] + } +} diff --git a/src/main/services/teacher/structuredResultParser.ts b/src/main/services/teacher/structuredResultParser.ts new file mode 100644 index 0000000..16ff282 --- /dev/null +++ b/src/main/services/teacher/structuredResultParser.ts @@ -0,0 +1,96 @@ +import type { StructuredTeacherResult, TeacherTaskType } from './resultSchema' + +function extractJson(text: string): unknown | null { + const trimmed = text.trim() + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i) + const candidate = fenced?.[1]?.trim() ?? trimmed + if (!candidate.startsWith('{')) return null + try { + return JSON.parse(candidate) as unknown + } catch { + return null + } +} + +function asString(value: unknown, defaultValue = ''): string { + return typeof value === 'string' ? value : defaultValue +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : [] +} + +function normalizeSeverity(value: unknown): 'inaccuracy' | 'mistake' | 'blunder' { + if (value === 'blunder' || value === 'mistake' || value === 'inaccuracy') return value + return 'mistake' +} + +function normalizeTaskType(value: unknown, defaultValue: TeacherTaskType): TeacherTaskType { + if (value === 'current-move' || value === 'full-game' || value === 'recent-games' || value === 'freeform') return value + return defaultValue +} + +function normalizeColor(value: unknown): 'B' | 'W' | undefined { + return value === 'B' || value === 'W' ? value : undefined +} + +export function parseStructuredTeacherResult(input: { + text: string + taskType: TeacherTaskType + knowledgeCardIds?: string[] +}): StructuredTeacherResult { + const json = extractJson(input.text) + if (json && typeof json === 'object') { + const obj = json as Record + const keyMistakes = Array.isArray(obj.keyMistakes) + ? obj.keyMistakes.map((item) => { + const row = item && typeof item === 'object' ? item as Record : {} + return { + moveNumber: typeof row.moveNumber === 'number' ? row.moveNumber : undefined, + color: normalizeColor(row.color), + played: asString(row.played), + recommended: asString(row.recommended), + errorType: asString(row.errorType), + severity: normalizeSeverity(row.severity), + evidence: asString(row.evidence), + explanation: asString(row.explanation) + } + }) + : [] + + const result: StructuredTeacherResult = { + taskType: normalizeTaskType(obj.taskType, input.taskType), + headline: asString(obj.headline), + summary: asString(obj.summary, input.text.split('\n').find((line) => line.trim())?.trim() ?? ''), + keyMistakes, + correctThinking: asStringArray(obj.correctThinking), + drills: asStringArray(obj.drills), + followupQuestions: asStringArray(obj.followupQuestions), + markdown: asString(obj.markdown) || input.text, + knowledgeCardIds: asStringArray(obj.knowledgeCardIds).length > 0 ? asStringArray(obj.knowledgeCardIds) : input.knowledgeCardIds ?? [], + profileUpdates: { + errorTypes: asStringArray((obj.profileUpdates as Record | undefined)?.errorTypes), + patterns: asStringArray((obj.profileUpdates as Record | undefined)?.patterns), + trainingFocus: asStringArray((obj.profileUpdates as Record | undefined)?.trainingFocus) + } + } + return result + } + + return { + taskType: input.taskType, + headline: '', + summary: input.text.split('\n').find((line) => line.trim())?.trim() ?? '', + keyMistakes: [], + correctThinking: [], + drills: [], + followupQuestions: [], + markdown: input.text, + knowledgeCardIds: input.knowledgeCardIds ?? [], + profileUpdates: { + errorTypes: [], + patterns: [], + trainingFocus: [] + } + } +} diff --git a/src/main/services/teacher/toolLogBuilder.ts b/src/main/services/teacher/toolLogBuilder.ts new file mode 100644 index 0000000..eb97364 --- /dev/null +++ b/src/main/services/teacher/toolLogBuilder.ts @@ -0,0 +1,55 @@ +export type ToolLogStatus = 'running' | 'done' | 'error' | 'skipped' + +export interface TeacherToolLog { + id: string + label: string + status: ToolLogStatus + detail: string + startedAt?: string + finishedAt?: string + errorCode?: string +} + +export function startToolLog(id: string, label: string, detail = ''): TeacherToolLog { + return { + id, + label, + status: 'running', + detail, + startedAt: new Date().toISOString() + } +} + +export function finishToolLog(log: TeacherToolLog, detail?: string): TeacherToolLog { + return { + ...log, + status: 'done', + detail: detail ?? log.detail, + finishedAt: new Date().toISOString() + } +} + +export function failToolLog(log: TeacherToolLog, error: unknown, errorCode?: string): TeacherToolLog { + return { + ...log, + status: 'error', + detail: error instanceof Error ? error.message : String(error), + errorCode, + finishedAt: new Date().toISOString() + } +} + +export function skippedToolLog(id: string, label: string, detail: string): TeacherToolLog { + return { + id, + label, + status: 'skipped', + detail, + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString() + } +} + +export function compactToolLogsForPrompt(logs: TeacherToolLog[]): string { + return logs.map((log) => `${log.label}: ${log.status} - ${log.detail}`).join('\n') +} diff --git a/src/main/services/teacherAgent.ts b/src/main/services/teacherAgent.ts index ec60cec..3743500 100644 --- a/src/main/services/teacherAgent.ts +++ b/src/main/services/teacherAgent.ts @@ -6,23 +6,36 @@ import type { CoachUserLevel, GameMove, KataGoMoveAnalysis, + KnowledgeMatch, KnowledgePacket, LibraryGame, + RecommendedProblem, ReviewArtifact, + StructuredTeacherResult, StudentProfile, TeacherRunRequest, + TeacherRunProgress, TeacherRunResult, TeacherToolLog } from '@main/lib/types' import { analyzePosition } from './katago' -import { searchKnowledge } from './knowledge' +import { searchKnowledge, searchKnowledgeMatches } from './knowledge' +import { recommendedProblemsFromMatches, type BoardSnapshotStone, type LocalWindow } from './knowledge/matchEngine' import { readGameRecord } from './sgf' +import { ensureFoxGameDownloaded } from './fox' import { callMultimodalTeacher, callTeacherText } from './llm' -import { getStudentProfile, updateStudentProfile } from './studentProfile' +import { getStudentProfile, readStudentForGame, updateStudentProfile } from './studentProfile' import { runReview } from './review' import { applyDetectedDefaults, detectSystemProfile } from './systemProfile' +import { parseStructuredTeacherResult } from './teacher/structuredResultParser' type TeacherIntent = 'current-move' | 'game-review' | 'batch-review' | 'training-plan' | 'open-ended' +type TeacherProgressEmitter = (progress: TeacherRunProgress) => void + +interface TeacherRunContext { + runId: string + emit?: TeacherProgressEmitter +} interface TeacherToolDefinition { name: string @@ -58,8 +71,8 @@ const TOOL_CATALOG: TeacherToolDefinition[] = [ }, { name: 'settings.writeAppConfig', - purpose: '把探测到的 KataGo 路径、配置、模型和本机代理写入 KataSensei 应用配置。', - privateInputs: ['KataSensei 本地设置', '本机代理 API key'] + purpose: '把探测到的 KataGo 路径、配置、模型和本机代理写入 GoMentor 应用配置。', + privateInputs: ['GoMentor 本地设置', '本机代理 API key'] }, { name: 'katago.verifyAnalysis', @@ -124,6 +137,32 @@ function finishTool(log: TeacherToolLog, status: TeacherToolLog['status'], detai log.endedAt = new Date().toISOString() } +function cloneToolLogs(logs: TeacherToolLog[]): TeacherToolLog[] { + return logs.map((log) => ({ ...log })) +} + +function emitProgress(context: TeacherRunContext | undefined, progress: Omit): void { + context?.emit?.({ + runId: context.runId, + ...progress + }) +} + +function emitToolState(context: TeacherRunContext | undefined, logs: TeacherToolLog[], message: string): void { + emitProgress(context, { + stage: 'tool', + message, + toolLogs: cloneToolLogs(logs) + }) +} + +function emitAssistantDelta(context: TeacherRunContext | undefined, delta: string): void { + emitProgress(context, { + stage: 'assistant-delta', + markdownDelta: delta + }) +} + function classifyIntent(request: TeacherRunRequest): TeacherIntent { if (request.mode === 'current-move') { return 'current-move' @@ -179,6 +218,104 @@ 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 { + const match = point.trim().toUpperCase().match(/^([A-HJ-T])(\d{1,2})$/) + if (!match) return null + const letters = 'ABCDEFGHJKLMNOPQRST' + const col = letters.indexOf(match[1]) + const number = Number(match[2]) + if (col < 0 || col >= boardSize || number < 1 || number > boardSize) return null + return { row: boardSize - number, col } +} + +function coordToGtp(row: number, col: number, boardSize: number): string { + const letters = 'ABCDEFGHJKLMNOPQRST' + return `${letters[col]}${boardSize - row}` +} + +function coordKey(row: number, col: number): string { + return `${row},${col}` +} + +function neighborsOf(row: number, col: number, boardSize: number): Array<{ row: number; col: number }> { + return [ + { row: row - 1, col }, + { row: row + 1, col }, + { row, col: col - 1 }, + { row, col: col + 1 } + ].filter((point) => point.row >= 0 && point.col >= 0 && point.row < boardSize && point.col < boardSize) +} + +function collectGroup(board: Map, row: number, col: number, boardSize: number): Array<{ row: number; col: number }> { + const color = board.get(coordKey(row, col)) + if (!color) return [] + const seen = new Set() + const group: Array<{ row: number; col: number }> = [] + const stack = [{ row, col }] + while (stack.length > 0) { + const current = stack.pop()! + const key = coordKey(current.row, current.col) + if (seen.has(key)) continue + if (board.get(key) !== color) continue + seen.add(key) + group.push(current) + for (const next of neighborsOf(current.row, current.col, boardSize)) { + if (board.get(coordKey(next.row, next.col)) === color) { + stack.push(next) + } + } + } + return group +} + +function groupHasLiberty(board: Map, group: Array<{ row: number; col: number }>, boardSize: number): boolean { + return group.some((stone) => neighborsOf(stone.row, stone.col, boardSize).some((next) => !board.has(coordKey(next.row, next.col)))) +} + +function buildBoardSnapshot(moves: GameMove[], uptoMoveNumber: number, boardSize: number): BoardSnapshotStone[] { + 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) + if (!coord) continue + const key = coordKey(coord.row, coord.col) + board.set(key, move.color) + const opponent = move.color === 'B' ? 'W' : 'B' + for (const next of neighborsOf(coord.row, coord.col, boardSize)) { + if (board.get(coordKey(next.row, next.col)) !== opponent) continue + const group = collectGroup(board, next.row, next.col, boardSize) + if (!groupHasLiberty(board, group, boardSize)) { + for (const stone of group) board.delete(coordKey(stone.row, stone.col)) + } + } + const ownGroup = collectGroup(board, coord.row, coord.col, boardSize) + if (!groupHasLiberty(board, ownGroup, boardSize)) { + for (const stone of ownGroup) board.delete(coordKey(stone.row, stone.col)) + } + } + return [...board.entries()].map(([key, color]) => { + const [row, col] = key.split(',').map(Number) + return { color, point: coordToGtp(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)) + .map((anchor) => { + const anchorPoint = gtpToCoord(anchor, boardSize)! + return { + anchor, + stones: snapshot.filter((stone) => { + const point = gtpToCoord(stone.point, boardSize) + if (!point) return false + return Math.max(Math.abs(point.row - anchorPoint.row), Math.abs(point.col - anchorPoint.col)) <= 4 + }) + } + }) + .filter((window) => window.stones.length > 0) +} + function tagsFromAnalysis(analysis: KataGoMoveAnalysis, move?: GameMove): string[] { const tags = new Set() if (analysis.moveNumber <= 40) { @@ -186,7 +323,7 @@ function tagsFromAnalysis(analysis: KataGoMoveAnalysis, move?: GameMove): string tags.add('方向') tags.add('大场') } - if ((analysis.playedMove?.scoreLoss ?? 0) >= 3) { + if ((analysis.playedMove?.winrateLoss ?? 0) >= 4) { tags.add('急所') tags.add('价值判断') } @@ -239,21 +376,41 @@ function themesFromProfile(profile: StudentProfile): string[] { }) } +function weaknessTagsFromMatches(matches: KnowledgeMatch[]): { + josekiWeaknesses: string[] + lifeDeathWeaknesses: string[] + tesujiWeaknesses: string[] +} { + const strong = matches.filter((match) => match.confidence !== 'weak') + return { + josekiWeaknesses: strong.filter((match) => match.matchType === 'joseki').map((match) => match.title).slice(0, 6), + lifeDeathWeaknesses: strong.filter((match) => match.matchType === 'life_death').map((match) => match.title).slice(0, 6), + tesujiWeaknesses: strong.filter((match) => match.matchType === 'tesuji').map((match) => match.title).slice(0, 6) + } +} + +function knowledgeFocusFromMatches(matches: KnowledgeMatch[], problems: RecommendedProblem[]): string[] { + return Array.from(new Set([ + ...matches.filter((match) => match.confidence !== 'weak').slice(0, 4).map((match) => match.title), + ...problems.slice(0, 3).map((problem) => problem.title) + ])).slice(0, 6) +} + function systemPrompt(level: CoachUserLevel): string { const levelLine: Record = { - beginner: '学生是入门水平,请少用术语,句子短,先讲方向再讲变化。', - intermediate: '学生是业余中级,可以使用常见术语,但要把判断步骤讲清楚。', - advanced: '学生是业余高级,可以直接讨论厚薄、目差、PV 和攻防转换。', - dan: '学生是高段水平,请简洁、精确,重点讲判断和变化。' + beginner: '学生是入门水平,少用术语。', + intermediate: '学生是业余中级,讲清判断顺序。', + advanced: '学生是业余高级,可以直接讲厚薄、目差和变化。', + dan: '学生是高段水平,简洁精确。' } return [ - '你是 KataSensei 的围棋老师智能体。', - '你不是固定按钮流程,也不是只会复盘当前手的聊天机器人;你会像 Claude Code 一样先理解任务、规划步骤、选择工具、整合结果。', - '只要工具目录里存在能力,就可以为了完成学生的学习目标主动使用;不要因为任务不属于预设模板就拒绝或改写成模板。', - 'KataGo 结构化数据永远是事实裁判;棋盘截图只用于视觉理解;知识库只用于教学解释。', - '不要编造坐标、胜率、目差或不存在的变化。', + '你是 GoMentor 的围棋老师。', + 'KataGo 数据是事实依据;截图只辅助看棋;知识库只用于解释。', + '不要编造坐标、胜率、目差、定式名或变化。', + '像真人老师一样自然讲棋,不套固定栏目。', + '数据点到为止,少写报告腔。', levelLine[level], - '输出中文 Markdown,先给结论,再讲原因,最后给一个可执行训练动作。' + '输出中文。' ].join('\n') } @@ -269,16 +426,26 @@ function currentMovePayload( request: TeacherRunRequest, analysis: KataGoMoveAnalysis, knowledge: KnowledgePacket[], - profile: StudentProfile + profile: StudentProfile, + knowledgeMatches: KnowledgeMatch[] = [], + recommendedProblems: RecommendedProblem[] = [] ): string { return JSON.stringify({ task: 'analyze_current_move', userPrompt: request.prompt, gameId: analysis.gameId, moveNumber: analysis.moveNumber, + katagoPerspective: { + rawWinrate: 'BLACK', + rawScoreLead: 'BLACK', + displayedCandidateValues: 'current_player_to_move', + problemMoveBasis: 'best_before_move_value_minus_played_move_value_from_current_player_perspective' + }, katagoFacts: analysis, studentProfile: profile, - knowledgePacket: knowledge + knowledgePacket: knowledge, + knowledgeMatches, + recommendedProblems }, null, 2) } @@ -310,6 +477,7 @@ async function maybeSearchWeb(prompt: string, logs: TeacherToolLog[]): Promise token.length >= 2).slice(0, 8)], - maxResults: 6 + maxResults: 4 }) } @@ -332,20 +500,48 @@ function saveReport(id: string, title: string, markdown: string, extra: Record { +function structuredFromTeacherText( + markdown: string, + taskType: StructuredTeacherResult['taskType'], + knowledge: KnowledgePacket[], + knowledgeMatches: KnowledgeMatch[] = [], + recommendedProblems: RecommendedProblem[] = [] +): StructuredTeacherResult { + const parsed = parseStructuredTeacherResult({ + text: markdown, + taskType, + knowledgeCardIds: knowledge.map((card) => card.id) + }) as StructuredTeacherResult + return { + ...parsed, + knowledgeMatches: parsed.knowledgeMatches?.length ? parsed.knowledgeMatches : knowledgeMatches, + recommendedProblems: parsed.recommendedProblems?.length ? parsed.recommendedProblems : recommendedProblems, + profileUpdates: { + ...parsed.profileUpdates, + patterns: parsed.profileUpdates.patterns, + trainingFocus: parsed.profileUpdates.trainingFocus + } + } +} + +async function runCurrentMove(request: TeacherRunRequest, logs: TeacherToolLog[], id: string, context?: TeacherRunContext): Promise { if (!request.gameId) { throw new Error('当前手分析需要先选择棋谱。') } - const game = getGames().find((item) => item.id === request.gameId) - const studentName = detectStudentName(request, game) - const profile = getStudentProfile(studentName) + const indexedGame = getGames().find((item) => item.id === request.gameId) + const game = indexedGame ? await ensureFoxGameDownloaded(indexedGame) : undefined + const boundProfile = readStudentForGame(request.gameId) + const studentName = boundProfile?.displayName ?? detectStudentName(request, game) + const profile = boundProfile ?? getStudentProfile(studentName) const record = game ? readGameRecord(game) : undefined const moveNumber = Math.max(0, Math.min(request.moveNumber ?? record?.moves.length ?? 0, record?.moves.length ?? 0)) const boardLog = startTool(logs, 'board.captureTeachingImage', '棋盘截图', request.boardImageDataUrl ? '已收到前端生成的教学棋盘 PNG。' : '未收到棋盘截图。') finishTool(boardLog, request.boardImageDataUrl ? 'done' : 'error') + emitToolState(context, logs, '已确认棋盘截图和当前手上下文。') const analysisLog = startTool(logs, 'katago.analyzePosition', 'KataGo 当前局面', `分析第 ${moveNumber} 手前后局面。`) + emitToolState(context, logs, '正在读取 KataGo 当前局面分析。') const analysis = request.prefetchedAnalysis ?? await analyzePosition(request.gameId, moveNumber) finishTool( analysisLog, @@ -354,23 +550,47 @@ async function runCurrentMove(request: TeacherRunRequest, logs: TeacherToolLog[] ? `复用前端预分析结果,推荐 ${analysis.before.topMoves[0]?.move ?? '未知'}。` : `推荐 ${analysis.before.topMoves[0]?.move ?? '未知'},实战损失约 ${(analysis.playedMove?.winrateLoss ?? 0).toFixed(1)}%。` ) + emitToolState(context, logs, 'KataGo 证据已就绪,开始找教学概念。') - const knowledgeLog = startTool(logs, 'knowledge.searchLocal', '本地知识库', '按阶段、区域、学生水平和 KataGo 损失检索 YiGo 知识库。') - const knowledge = searchKnowledge({ + const knowledgeLog = startTool(logs, 'knowledge.searchLocal', '本地知识库', '按阶段、区域、学生水平和 KataGo 损失检索定式、死活、手筋和教学卡。') + const boardSnapshot = record ? buildBoardSnapshot(record.moves, Math.max(0, moveNumber - 1), record.boardSize) : undefined + const localWindows = boardSnapshot + ? buildLocalWindows(boardSnapshot, [ + analysis.playedMove?.move ?? analysis.currentMove?.gtp, + ...analysis.before.topMoves.slice(0, 6).map((candidate) => candidate.move), + ...analysis.before.topMoves.slice(0, 2).flatMap((candidate) => candidate.pv.slice(0, 4)) + ], record?.boardSize ?? analysis.boardSize) + : undefined + const knowledgeQuery = { + text: request.prompt, moveNumber, totalMoves: record?.moves.length ?? moveNumber, boardSize: record?.boardSize ?? analysis.boardSize, recentMoves: record?.moves.slice(Math.max(0, moveNumber - 5), moveNumber) ?? [], userLevel: profile.userLevel, + studentLevel: profile.userLevel, + playerColor: analysis.currentMove?.color, lossScore: analysis.playedMove?.scoreLoss, judgement: analysis.judgement, - contextTags: tagsFromAnalysis(analysis, analysis.currentMove) - }) - finishTool(knowledgeLog, 'done', `选中 ${knowledge.length} 条知识卡片。`) + contextTags: tagsFromAnalysis(analysis, analysis.currentMove), + playedMove: analysis.playedMove?.move ?? analysis.currentMove?.gtp, + candidateMoves: analysis.before.topMoves.slice(0, 8).map((candidate) => candidate.move), + principalVariation: analysis.before.topMoves.slice(0, 3).flatMap((candidate) => candidate.pv.slice(0, 8)), + boardSnapshot, + localWindows, + maxResults: 4 + } + const knowledgeMatches = searchKnowledgeMatches({ ...knowledgeQuery, maxResults: 8 }) + const recommendedProblems = recommendedProblemsFromMatches(knowledgeMatches, 3) + const knowledge = searchKnowledge(knowledgeQuery) + finishTool(knowledgeLog, 'done', `选中 ${knowledge.length} 条知识卡片,匹配 ${knowledgeMatches.length} 个定式/死活/手筋型,推荐 ${recommendedProblems.length} 道训练题。`) + emitToolState(context, logs, '本地知识卡片已选好,准备交给老师组织讲解。') const webSnippets = await maybeSearchWeb(request.prompt, logs) + emitToolState(context, logs, '上下文收集完成,开始流式生成讲解。') const llmLog = startTool(logs, 'llm.multimodalTeacher', '多模态老师', '发送棋盘截图、KataGo JSON 和知识包给多模态模型。') + emitProgress(context, { stage: 'assistant-start', message: '开始流式生成当前手讲解。', toolLogs: cloneToolLogs(logs) }) let markdown = '' if (!request.boardImageDataUrl) { markdown = '当前手分析需要棋盘截图。请重新点击“分析当前手”,让前端生成教学棋盘 PNG。' @@ -380,8 +600,9 @@ async function runCurrentMove(request: TeacherRunRequest, logs: TeacherToolLog[] markdown = await callMultimodalTeacher( getSettings(), systemPrompt(profile.userLevel), - currentMovePayload(request, analysis, knowledge, profile) + (webSnippets.length ? `\n\n外部资料标题:\n${webSnippets.join('\n')}` : ''), - request.boardImageDataUrl + currentMovePayload(request, analysis, knowledge, profile, knowledgeMatches, recommendedProblems) + (webSnippets.length ? `\n\n外部资料标题:\n${webSnippets.join('\n')}` : ''), + request.boardImageDataUrl, + (delta) => emitAssistantDelta(context, delta) ) finishTool(llmLog, 'done', '老师讲解已生成。') } catch (error) { @@ -389,9 +610,18 @@ async function runCurrentMove(request: TeacherRunRequest, logs: TeacherToolLog[] finishTool(llmLog, 'error', markdown) } } + emitToolState(context, logs, llmLog.status === 'done' ? '老师讲解已生成。' : '老师讲解没有完成,保留当前错误说明。') - const updatedProfile = updateStudentProfile(studentName, { - mistakeTags: tagsFromAnalysis(analysis, analysis.currentMove), + let updatedProfile = updateStudentProfile(studentName, { + reviewedGames: 1, + mistakeTags: [...tagsFromAnalysis(analysis, analysis.currentMove), ...knowledgeMatches.filter((match) => match.confidence !== 'weak').map((match) => match.matchType)], + recentPatterns: [ + ...tagsFromAnalysis(analysis, analysis.currentMove).map((tag) => `第 ${moveNumber} 手出现${tag}相关问题`), + ...knowledgeMatches.filter((match) => match.confidence !== 'weak').slice(0, 3).map((match) => `第 ${moveNumber} 手匹配${match.title}(${match.confidence})`) + ], + trainingFocus: [...knowledgeFocusFromMatches(knowledgeMatches, recommendedProblems), ...knowledge.slice(0, 2).map((card) => card.title)], + ...weaknessTagsFromMatches(knowledgeMatches), + gameId: request.gameId, typicalMoves: analysis.playedMove ? [{ gameId: request.gameId, @@ -405,9 +635,18 @@ async function runCurrentMove(request: TeacherRunRequest, logs: TeacherToolLog[] const profileLog = startTool(logs, 'studentProfile.write', '学生画像', `更新 ${studentName} 的长期画像。`) finishTool(profileLog, 'done', `累计复盘 ${updatedProfile.gamesReviewed} 盘,记录 ${updatedProfile.commonMistakes.length} 类问题。`) + emitToolState(context, logs, '讲解已写入棋手画像和复盘报告。') const title = `第 ${moveNumber} 手分析` - const reportPath = saveReport(id, title, markdown, { analysis, knowledge, studentProfile: updatedProfile }) + const structured = structuredFromTeacherText( + markdown, + 'current-move', + knowledge, + knowledgeMatches, + recommendedProblems + ) + markdown = structured.markdown || markdown + const reportPath = saveReport(id, title, markdown, { analysis, knowledge, knowledgeMatches, recommendedProblems, studentProfile: updatedProfile, structured }) return { id, mode: 'current-move', @@ -416,7 +655,11 @@ async function runCurrentMove(request: TeacherRunRequest, logs: TeacherToolLog[] toolLogs: logs, analysis, knowledge, + knowledgeMatches, + recommendedProblems, studentProfile: updatedProfile, + structured, + structuredResult: structured, reportPath } } @@ -434,7 +677,7 @@ function extractIssues(artifact: ReviewArtifact | undefined, game: LibraryGame): })) } -async function runBatchReview(request: TeacherRunRequest, logs: TeacherToolLog[], id: string): Promise { +async function runBatchReview(request: TeacherRunRequest, logs: TeacherToolLog[], id: string, context?: TeacherRunContext): Promise { const count = inferCount(request.prompt) const selectedGame = request.gameId ? getGames().find((item) => item.id === request.gameId) : undefined const studentName = detectStudentName(request, selectedGame) @@ -442,9 +685,11 @@ async function runBatchReview(request: TeacherRunRequest, logs: TeacherToolLog[] const findLog = startTool(logs, 'library.findGames', '筛选棋谱', `查找 ${studentName} 最近 ${count} 盘棋。`) const games = findGamesForStudent(studentName, count) finishTool(findLog, games.length > 0 ? 'done' : 'error', `找到 ${games.length} 盘棋。`) + emitToolState(context, logs, `已找到 ${games.length} 盘候选棋谱。`) const issues: BatchIssue[] = [] const batchLog = startTool(logs, 'katago.analyzeGameBatch', '批量 KataGo', `顺序分析 ${games.length} 盘棋,提取关键问题手。`) + emitToolState(context, logs, '开始批量扫描关键问题手。') for (const game of games) { try { const result = await runReview({ @@ -468,10 +713,16 @@ async function runBatchReview(request: TeacherRunRequest, logs: TeacherToolLog[] } } finishTool(batchLog, 'done', `提取 ${issues.filter((issue) => issue.loss > 0).length} 个关键问题点。`) + emitToolState(context, logs, '批量分析完成,正在聚合同类问题。') - const profileUpdate = updateStudentProfile(studentName, { + let profileUpdate = updateStudentProfile(studentName, { reviewedGames: games.length, mistakeTags: profileTagsFromIssues(issues.filter((issue) => issue.loss > 0)), + recentPatterns: issues + .filter((issue) => issue.loss > 0) + .slice(0, 8) + .map((issue) => `${issue.game.black} vs ${issue.game.white} 第${issue.moveNumber}手 ${issue.playedMove} 损失 ${issue.loss.toFixed(1)}%`), + trainingFocus: profileTagsFromIssues(issues.filter((issue) => issue.loss > 0)).slice(0, 6), typicalMoves: issues .filter((issue) => issue.loss > 0) .slice(0, 8) @@ -485,10 +736,26 @@ async function runBatchReview(request: TeacherRunRequest, logs: TeacherToolLog[] }) const profileLog = startTool(logs, 'studentProfile.write', '学生画像', '把批量分析结果沉淀为长期画像。') finishTool(profileLog, 'done', `画像已更新:${profileUpdate.commonMistakes.slice(0, 3).map((item) => item.tag).join('、') || '暂无稳定标签'}`) + emitToolState(context, logs, '棋手画像已更新。') const knowledgeLog = startTool(logs, 'knowledge.searchLocal', '本地知识库', '根据批量问题检索训练主题知识。') const themes = themesFromProfile(profileUpdate) - const knowledge = searchKnowledge({ + const topIssue = issues.filter((issue) => issue.loss > 0).sort((a, b) => b.loss - a.loss)[0] + let batchIssueBoardSnapshot: BoardSnapshotStone[] | undefined + let batchIssueLocalWindows: LocalWindow[] | undefined + if (topIssue) { + try { + const topGame = await ensureFoxGameDownloaded(topIssue.game) + const topRecord = readGameRecord(topGame) + batchIssueBoardSnapshot = buildBoardSnapshot(topRecord.moves, Math.max(0, topIssue.moveNumber - 1), topRecord.boardSize) + batchIssueLocalWindows = buildLocalWindows(batchIssueBoardSnapshot, [topIssue.playedMove, topIssue.bestMove, ...topIssue.pv.slice(0, 4)], topRecord.boardSize) + } catch { + batchIssueBoardSnapshot = undefined + batchIssueLocalWindows = undefined + } + } + const knowledgeQuery = { + text: request.prompt, moveNumber: 60, totalMoves: 180, boardSize: 19, @@ -497,11 +764,30 @@ async function runBatchReview(request: TeacherRunRequest, logs: TeacherToolLog[] lossScore: Math.max(...issues.map((issue) => issue.loss), 0) / 2, judgement: issues.some((issue) => issue.loss >= 15) ? 'blunder' : 'mistake', contextTags: ['布局', '方向', '手筋', '形势判断', ...themes], - maxResults: 6 - }) - finishTool(knowledgeLog, 'done', `选中 ${knowledge.length} 条训练参考。`) + playedMove: topIssue?.playedMove, + candidateMoves: topIssue?.bestMove ? [topIssue.bestMove] : [], + principalVariation: topIssue?.pv ?? [], + boardSnapshot: batchIssueBoardSnapshot, + localWindows: batchIssueLocalWindows, + maxResults: 4 + } + const knowledgeMatches = searchKnowledgeMatches({ ...knowledgeQuery, maxResults: 8 }) + const recommendedProblems = recommendedProblemsFromMatches(knowledgeMatches, 3) + const knowledge = searchKnowledge(knowledgeQuery) + if (knowledgeMatches.some((match) => match.confidence !== 'weak')) { + profileUpdate = updateStudentProfile(studentName, { + reviewedGames: 0, + mistakeTags: knowledgeMatches.filter((match) => match.confidence !== 'weak').map((match) => match.matchType), + recentPatterns: knowledgeMatches.filter((match) => match.confidence !== 'weak').slice(0, 4).map((match) => `最近对局高频匹配:${match.title}`), + trainingFocus: knowledgeFocusFromMatches(knowledgeMatches, recommendedProblems), + ...weaknessTagsFromMatches(knowledgeMatches) + }) + } + finishTool(knowledgeLog, 'done', `选中 ${knowledge.length} 条训练参考,匹配 ${knowledgeMatches.length} 个棋形,推荐 ${recommendedProblems.length} 道训练题。`) + emitToolState(context, logs, '训练主题知识卡片已就绪。') const llmLog = startTool(logs, 'llm.teacherAgent', '老师总结', '让老师自己判断输出学生画像、典型错手还是训练计划。') + emitProgress(context, { stage: 'assistant-start', message: '开始流式生成最近对局总结。', toolLogs: cloneToolLogs(logs) }) let markdown = '' try { markdown = await callTeacherText(getSettings(), systemPrompt(profileUpdate.userLevel), JSON.stringify({ @@ -511,8 +797,10 @@ async function runBatchReview(request: TeacherRunRequest, logs: TeacherToolLog[] games: games.map((game) => ({ id: game.id, title: game.title, black: game.black, white: game.white, result: game.result, date: game.date })), issues: issues.filter((issue) => issue.loss > 0).slice(0, 20), studentProfile: profileUpdate, - knowledgePacket: knowledge - }, null, 2)) + knowledgePacket: knowledge, + knowledgeMatches, + recommendedProblems + }, null, 2), (delta) => emitAssistantDelta(context, delta)) finishTool(llmLog, 'done', '批量分析总结已生成。') } catch (error) { markdown = [ @@ -525,7 +813,15 @@ async function runBatchReview(request: TeacherRunRequest, logs: TeacherToolLog[] } const title = `${studentName} 最近 ${games.length} 盘画像` - const reportPath = saveReport(id, title, markdown, { games, issues, knowledge, studentProfile: profileUpdate }) + const structured = structuredFromTeacherText( + markdown, + 'recent-games', + knowledge, + knowledgeMatches, + recommendedProblems + ) + markdown = structured.markdown || markdown + const reportPath = saveReport(id, title, markdown, { games, issues, knowledge, knowledgeMatches, recommendedProblems, studentProfile: profileUpdate, structured }) return { id, mode: 'freeform', @@ -533,27 +829,35 @@ async function runBatchReview(request: TeacherRunRequest, logs: TeacherToolLog[] markdown, toolLogs: logs, knowledge, + knowledgeMatches, + recommendedProblems, studentProfile: profileUpdate, + structured, + structuredResult: structured, reportPath } } -async function runGameReview(request: TeacherRunRequest, logs: TeacherToolLog[], id: string): Promise { +async function runGameReview(request: TeacherRunRequest, logs: TeacherToolLog[], id: string, context?: TeacherRunContext): Promise { if (!request.gameId) { throw new Error('整盘分析需要先选择棋谱。') } - const game = getGames().find((item) => item.id === request.gameId) - if (!game) { + const indexedGame = getGames().find((item) => item.id === request.gameId) + if (!indexedGame) { throw new Error('找不到当前棋谱。') } - const studentName = detectStudentName(request, game) - const profile = getStudentProfile(studentName) + const game = await ensureFoxGameDownloaded(indexedGame) + const boundProfile = readStudentForGame(game.id) + const studentName = boundProfile?.displayName ?? detectStudentName(request, game) + const profile = boundProfile ?? getStudentProfile(studentName) const sgfLog = startTool(logs, 'sgf.readGameRecord', '读取整盘棋谱', `读取 ${game.black} vs ${game.white} 的主线。`) const record = readGameRecord(game) finishTool(sgfLog, 'done', `读取 ${record.moves.length} 手,结果 ${game.result || '未知'}。`) + emitToolState(context, logs, `已读取 ${record.moves.length} 手主线。`) const reviewLog = startTool(logs, 'katago.analyzeGameBatch', '整盘 KataGo', '分析当前整盘棋,提取关键问题手和胜率损失。') + emitToolState(context, logs, '开始整盘 KataGo 快速扫描。') const review = await runReview({ gameId: game.id, playerName: studentName, @@ -563,10 +867,14 @@ async function runGameReview(request: TeacherRunRequest, logs: TeacherToolLog[], }) const issues = extractIssues(review.artifact, game).filter((issue) => issue.loss > 0) finishTool(reviewLog, 'done', `提取 ${issues.length} 个关键问题手。`) + emitToolState(context, logs, `已提取 ${issues.length} 个关键问题手。`) - const updatedProfile = updateStudentProfile(studentName, { + let updatedProfile = updateStudentProfile(studentName, { reviewedGames: 1, mistakeTags: profileTagsFromIssues(issues), + recentPatterns: issues.slice(0, 8).map((issue) => `${game.black} vs ${game.white} 第${issue.moveNumber}手 ${issue.playedMove} 推荐 ${issue.bestMove}`), + trainingFocus: profileTagsFromIssues(issues).slice(0, 6), + gameId: game.id, typicalMoves: issues.slice(0, 8).map((issue) => ({ gameId: game.id, moveNumber: issue.moveNumber, @@ -577,9 +885,16 @@ async function runGameReview(request: TeacherRunRequest, logs: TeacherToolLog[], }) const profileLog = startTool(logs, 'studentProfile.write', '学生画像', `把 ${studentName} 的本局问题写入长期画像。`) finishTool(profileLog, 'done', `画像累计 ${updatedProfile.gamesReviewed} 盘,问题类型 ${updatedProfile.commonMistakes.length} 类。`) + emitToolState(context, logs, '本局问题已写入棋手画像。') const knowledgeLog = startTool(logs, 'knowledge.searchLocal', '本地知识库', '根据整盘关键问题检索教学主题。') - const knowledge = searchKnowledge({ + const topIssue = issues.filter((issue) => issue.loss > 0).sort((a, b) => b.loss - a.loss)[0] + const issueBoardSnapshot = topIssue ? buildBoardSnapshot(record.moves, Math.max(0, topIssue.moveNumber - 1), record.boardSize) : undefined + const issueLocalWindows = issueBoardSnapshot + ? buildLocalWindows(issueBoardSnapshot, [topIssue?.playedMove, topIssue?.bestMove, ...(topIssue?.pv ?? []).slice(0, 4)], record.boardSize) + : undefined + const knowledgeQuery = { + text: request.prompt, moveNumber: issues[0]?.moveNumber ?? Math.min(80, record.moves.length), totalMoves: record.moves.length, boardSize: record.boardSize, @@ -588,11 +903,30 @@ async function runGameReview(request: TeacherRunRequest, logs: TeacherToolLog[], lossScore: Math.max(...issues.map((issue) => issue.loss), 0) / 2, judgement: issues.some((issue) => issue.loss >= 15) ? 'blunder' : 'mistake', contextTags: ['整盘复盘', '关键手', '形势判断', ...profileTagsFromIssues(issues)], - maxResults: 6 - }) - finishTool(knowledgeLog, 'done', `选中 ${knowledge.length} 条知识卡片。`) + playedMove: topIssue?.playedMove, + candidateMoves: topIssue?.bestMove ? [topIssue.bestMove] : [], + principalVariation: topIssue?.pv ?? [], + boardSnapshot: issueBoardSnapshot, + localWindows: issueLocalWindows, + maxResults: 4 + } + const knowledgeMatches = searchKnowledgeMatches({ ...knowledgeQuery, maxResults: 8 }) + const recommendedProblems = recommendedProblemsFromMatches(knowledgeMatches, 3) + const knowledge = searchKnowledge(knowledgeQuery) + if (knowledgeMatches.some((match) => match.confidence !== 'weak')) { + updatedProfile = updateStudentProfile(studentName, { + reviewedGames: 0, + mistakeTags: knowledgeMatches.filter((match) => match.confidence !== 'weak').map((match) => match.matchType), + recentPatterns: knowledgeMatches.filter((match) => match.confidence !== 'weak').slice(0, 4).map((match) => `本局关键问题匹配:${match.title}`), + trainingFocus: knowledgeFocusFromMatches(knowledgeMatches, recommendedProblems), + ...weaknessTagsFromMatches(knowledgeMatches) + }) + } + finishTool(knowledgeLog, 'done', `选中 ${knowledge.length} 条知识卡片,匹配 ${knowledgeMatches.length} 个棋形,推荐 ${recommendedProblems.length} 道训练题。`) + emitToolState(context, logs, '整盘复盘知识卡片已就绪。') const llmLog = startTool(logs, 'llm.teacherAgent', '老师整盘总结', '让老师结合整盘 KataGo 问题手和知识库生成复盘。') + emitProgress(context, { stage: 'assistant-start', message: '开始流式生成整盘复盘。', toolLogs: cloneToolLogs(logs) }) let markdown = '' try { markdown = await callTeacherText(getSettings(), systemPrompt(updatedProfile.userLevel), JSON.stringify({ @@ -609,8 +943,10 @@ async function runGameReview(request: TeacherRunRequest, logs: TeacherToolLog[], }, issues: issues.slice(0, 16), studentProfile: updatedProfile, - knowledgePacket: knowledge - }, null, 2)) + knowledgePacket: knowledge, + knowledgeMatches, + recommendedProblems + }, null, 2), (delta) => emitAssistantDelta(context, delta)) finishTool(llmLog, 'done', '整盘复盘已生成。') } catch (error) { markdown = [ @@ -623,7 +959,15 @@ async function runGameReview(request: TeacherRunRequest, logs: TeacherToolLog[], } const title = `${game.black} vs ${game.white} 整盘复盘` - const reportPath = saveReport(id, title, markdown, { game, issues, knowledge, studentProfile: updatedProfile }) + const structured = structuredFromTeacherText( + markdown, + 'full-game', + knowledge, + knowledgeMatches, + recommendedProblems + ) + markdown = structured.markdown || markdown + const reportPath = saveReport(id, title, markdown, { game, issues, knowledge, knowledgeMatches, recommendedProblems, studentProfile: updatedProfile, structured }) return { id, mode: 'freeform', @@ -631,20 +975,26 @@ async function runGameReview(request: TeacherRunRequest, logs: TeacherToolLog[], markdown, toolLogs: logs, knowledge, + knowledgeMatches, + recommendedProblems, studentProfile: updatedProfile, + structured, + structuredResult: structured, reportPath } } -async function runTrainingPlan(request: TeacherRunRequest, logs: TeacherToolLog[], id: string): Promise { +async function runTrainingPlan(request: TeacherRunRequest, logs: TeacherToolLog[], id: string, context?: TeacherRunContext): Promise { const studentName = detectStudentName(request) const profile = getStudentProfile(studentName) const profileLog = startTool(logs, 'studentProfile.read', '读取学生画像', `读取 ${studentName} 的长期画像。`) finishTool(profileLog, 'done', `已有 ${profile.gamesReviewed} 盘复盘记录。`) + emitToolState(context, logs, '已读取棋手画像。') const themes = themesFromProfile(profile) const knowledgeLog = startTool(logs, 'knowledge.searchLocal', '本地知识库', '围绕学生薄弱主题检索训练参考。') - const knowledge = searchKnowledge({ + const knowledgeQuery = { + text: request.prompt, moveNumber: 80, totalMoves: 180, boardSize: 19, @@ -653,11 +1003,16 @@ async function runTrainingPlan(request: TeacherRunRequest, logs: TeacherToolLog[ lossScore: 4, judgement: 'mistake', contextTags: themes, - maxResults: 6 - }) - finishTool(knowledgeLog, 'done', `选中 ${knowledge.length} 条知识卡片。`) + maxResults: 4 + } + const knowledgeMatches = searchKnowledgeMatches({ ...knowledgeQuery, maxResults: 8 }) + const recommendedProblems = recommendedProblemsFromMatches(knowledgeMatches, 3) + const knowledge = searchKnowledge(knowledgeQuery) + finishTool(knowledgeLog, 'done', `选中 ${knowledge.length} 条知识卡片,匹配 ${knowledgeMatches.length} 个训练主题,推荐 ${recommendedProblems.length} 道题。`) + emitToolState(context, logs, '训练主题知识卡片已就绪。') const llmLog = startTool(logs, 'llm.teacherAgent', '训练计划', '根据学生画像和知识库生成训练计划。') + emitProgress(context, { stage: 'assistant-start', message: '开始流式生成训练计划。', toolLogs: cloneToolLogs(logs) }) let markdown = '' try { markdown = await callTeacherText(getSettings(), systemPrompt(profile.userLevel), JSON.stringify({ @@ -665,8 +1020,10 @@ async function runTrainingPlan(request: TeacherRunRequest, logs: TeacherToolLog[ userPrompt: request.prompt, studentProfile: profile, suggestedThemes: themes, - knowledgePacket: knowledge - }, null, 2)) + knowledgePacket: knowledge, + knowledgeMatches, + recommendedProblems + }, null, 2), (delta) => emitAssistantDelta(context, delta)) finishTool(llmLog, 'done', '训练计划已生成。') } catch (error) { markdown = [ @@ -679,7 +1036,15 @@ async function runTrainingPlan(request: TeacherRunRequest, logs: TeacherToolLog[ } const title = `${studentName} 训练计划` - const reportPath = saveReport(id, title, markdown, { studentProfile: profile, knowledge }) + const structured = structuredFromTeacherText( + markdown, + 'freeform', + knowledge, + knowledgeMatches, + recommendedProblems + ) + markdown = structured.markdown || markdown + const reportPath = saveReport(id, title, markdown, { studentProfile: profile, knowledge, knowledgeMatches, recommendedProblems, structured }) return { id, mode: 'freeform', @@ -687,18 +1052,24 @@ async function runTrainingPlan(request: TeacherRunRequest, logs: TeacherToolLog[ markdown, toolLogs: logs, knowledge, + knowledgeMatches, + recommendedProblems, studentProfile: profile, + structured, + structuredResult: structured, reportPath } } -async function runOpenEndedTask(request: TeacherRunRequest, logs: TeacherToolLog[], id: string): Promise { - const game = request.gameId ? getGames().find((item) => item.id === request.gameId) : undefined +async function runOpenEndedTask(request: TeacherRunRequest, logs: TeacherToolLog[], id: string, context?: TeacherRunContext): Promise { + const indexedGame = request.gameId ? getGames().find((item) => item.id === request.gameId) : undefined + const game = indexedGame ? await ensureFoxGameDownloaded(indexedGame) : undefined const studentName = detectStudentName(request, game) let environmentSummary: Record | null = null if (shouldConfigureEnvironment(request.prompt)) { const detectLog = startTool(logs, 'system.detectEnvironment', '探测本机环境', '老师正在探测 KataGo、模型、配置和本机 LLM 代理。') + emitToolState(context, logs, '正在探测本机环境。') try { const detected = await detectSystemProfile() environmentSummary = { @@ -710,11 +1081,14 @@ async function runOpenEndedTask(request: TeacherRunRequest, logs: TeacherToolLog notes: detected.notes } finishTool(detectLog, 'done', detected.notes.join(';') || '探测完成,但没有发现可自动配置项。') + emitToolState(context, logs, '本机环境探测完成。') } catch (error) { finishTool(detectLog, 'error', `环境探测失败: ${String(error)}`) + emitToolState(context, logs, '本机环境探测失败,继续使用现有配置。') } - const writeLog = startTool(logs, 'settings.writeAppConfig', '写入应用配置', '把探测结果写入 KataSensei 本地配置,供老师后续直接调用。') + const writeLog = startTool(logs, 'settings.writeAppConfig', '写入应用配置', '把探测结果写入 GoMentor 本地配置,供老师后续直接调用。') + emitToolState(context, logs, '正在写入可用配置。') try { const nextSettings = await applyDetectedDefaults(getSettings()) replaceSettings(nextSettings) @@ -723,18 +1097,22 @@ async function runOpenEndedTask(request: TeacherRunRequest, logs: TeacherToolLog nextSettings.katagoConfig ? '配置已设置' : '配置未找到', nextSettings.katagoModel ? '模型已设置' : '模型未找到' ].join(';')) + emitToolState(context, logs, '应用配置已更新。') } catch (error) { finishTool(writeLog, 'error', `写入配置失败: ${String(error)}`) + emitToolState(context, logs, '配置写入失败,继续保持当前设置。') } } const profileLog = startTool(logs, 'studentProfile.read', '读取学生画像', `读取 ${studentName} 的长期画像,作为开放任务上下文。`) const profile = getStudentProfile(studentName) finishTool(profileLog, 'done', `已有 ${profile.gamesReviewed} 盘复盘记录。`) + emitToolState(context, logs, '已读取棋手画像。') let recordSummary: Record | null = null if (game) { const sgfLog = startTool(logs, 'sgf.readGameRecord', '读取当前棋谱', `读取当前棋谱 ${game.title},供老师自由判断任务。`) + emitToolState(context, logs, '正在读取当前棋谱上下文。') try { const record = readGameRecord(game) const moveNumber = Math.max(0, Math.min(request.moveNumber ?? record.moves.length, record.moves.length)) @@ -755,9 +1133,11 @@ async function runOpenEndedTask(request: TeacherRunRequest, logs: TeacherToolLog recentMoves: record.moves.slice(Math.max(0, moveNumber - 12), moveNumber) } finishTool(sgfLog, 'done', `读取 ${record.moves.length} 手,当前定位第 ${moveNumber} 手。`) + emitToolState(context, logs, '当前棋谱上下文已读取。') if (shouldConfigureEnvironment(request.prompt)) { const verifyLog = startTool(logs, 'katago.verifyAnalysis', '验证 KataGo', `用当前棋谱第 ${moveNumber} 手做低访问量验证分析。`) + emitToolState(context, logs, '正在验证 KataGo 可实际分析。') try { const verification = await analyzePosition(game.id, moveNumber, 80) environmentSummary = { @@ -770,24 +1150,45 @@ async function runOpenEndedTask(request: TeacherRunRequest, logs: TeacherToolLog } } finishTool(verifyLog, 'done', `验证成功:推荐 ${verification.before.topMoves[0]?.move ?? '未知'},当前胜率 ${verification.after.winrate.toFixed(1)}%。`) + emitToolState(context, logs, 'KataGo 验证分析成功。') } catch (error) { finishTool(verifyLog, 'error', `KataGo 验证失败: ${String(error)}`) + emitToolState(context, logs, 'KataGo 验证失败,老师会说明当前限制。') } } } catch (error) { finishTool(sgfLog, 'error', `棋谱读取失败: ${String(error)}`) + emitToolState(context, logs, '当前棋谱读取失败,继续使用其他上下文。') } } const knowledgeLog = startTool(logs, 'knowledge.searchLocal', '本地知识库', '开放任务先检索本地知识库,给老师可引用的教学概念。') const knowledge = genericKnowledgeForPrompt(request.prompt, profile) - finishTool(knowledgeLog, 'done', `选中 ${knowledge.length} 条知识卡片。`) + const recordContext = recordSummary as { currentMoveNumber?: number; totalMoves?: number; boardSize?: number; recentMoves?: GameMove[] } | null + const knowledgeMatches = searchKnowledgeMatches({ + text: request.prompt, + moveNumber: recordContext?.currentMoveNumber ?? 80, + totalMoves: recordContext?.totalMoves ?? 180, + boardSize: recordContext?.boardSize ?? 19, + recentMoves: recordContext?.recentMoves ?? [], + userLevel: profile.userLevel, + studentLevel: profile.userLevel, + lossScore: /错|问题|弱点|死活|手筋|定式/.test(request.prompt) ? 4 : 2, + judgement: /大错|严重|败着|崩/.test(request.prompt) ? 'blunder' : 'mistake', + contextTags: [...themesFromProfile(profile), ...request.prompt.split(/[,。!?\s,.!?]/).filter((token) => token.length >= 2).slice(0, 8)], + maxResults: 8 + }) + const recommendedProblems = recommendedProblemsFromMatches(knowledgeMatches, 3) + finishTool(knowledgeLog, 'done', `选中 ${knowledge.length} 条知识卡片,匹配 ${knowledgeMatches.length} 个棋形,推荐 ${recommendedProblems.length} 道题。`) + emitToolState(context, logs, '本地知识卡片已选好。') const webSnippets = await maybeSearchWeb(request.prompt, logs) const planningLog = startTool(logs, 'teacher.plan', '任务规划', '开放式任务不套模板,老师根据工具目录和上下文自行决定输出形式。') finishTool(planningLog, 'done', '已提供完整工具目录、当前棋局上下文、学生画像和知识库片段。') + emitToolState(context, logs, '任务上下文已组织好,开始回答。') const llmLog = startTool(logs, 'llm.teacherAgent', '开放式老师智能体', '把用户任务、工具目录、上下文和知识库交给老师推理。') + emitProgress(context, { stage: 'assistant-start', message: '开始流式生成回答。', toolLogs: cloneToolLogs(logs) }) let markdown = '' try { markdown = await callTeacherText(getSettings(), systemPrompt(profile.userLevel), JSON.stringify({ @@ -800,8 +1201,10 @@ async function runOpenEndedTask(request: TeacherRunRequest, logs: TeacherToolLog currentGameContext: recordSummary, environment: environmentSummary, knowledgePacket: knowledge, + knowledgeMatches, + recommendedProblems, webSearchTitles: webSnippets - }, null, 2)) + }, null, 2), (delta) => emitAssistantDelta(context, delta)) finishTool(llmLog, 'done', '开放式任务已生成。') } catch (error) { markdown = [ @@ -818,7 +1221,15 @@ async function runOpenEndedTask(request: TeacherRunRequest, logs: TeacherToolLog } const title = `${studentName} 开放任务` - const reportPath = saveReport(id, title, markdown, { studentProfile: profile, currentGameContext: recordSummary, environment: environmentSummary, knowledge, webSnippets, availableTools: toolCatalogPayload() }) + const structured = structuredFromTeacherText( + markdown, + 'freeform', + knowledge, + knowledgeMatches, + recommendedProblems + ) + markdown = structured.markdown || markdown + const reportPath = saveReport(id, title, markdown, { studentProfile: profile, currentGameContext: recordSummary, environment: environmentSummary, knowledge, knowledgeMatches, recommendedProblems, webSnippets, availableTools: toolCatalogPayload(), structured }) return { id, mode: 'freeform', @@ -826,27 +1237,63 @@ async function runOpenEndedTask(request: TeacherRunRequest, logs: TeacherToolLog markdown, toolLogs: logs, knowledge, + knowledgeMatches, + recommendedProblems, studentProfile: profile, + structured, + structuredResult: structured, reportPath } } -export async function runTeacherTask(request: TeacherRunRequest): Promise { - const id = randomUUID() +export async function runTeacherTask(request: TeacherRunRequest, onProgress?: TeacherProgressEmitter): Promise { + const id = request.runId || randomUUID() const logs: TeacherToolLog[] = [] const intent = classifyIntent(request) - - if (intent === 'current-move') { - return runCurrentMove(request, logs, id) - } - if (intent === 'game-review') { - return runGameReview(request, logs, id) + const context: TeacherRunContext = { + runId: id, + emit: onProgress } - if (intent === 'batch-review') { - return runBatchReview(request, logs, id) - } - if (intent === 'training-plan') { - return runTrainingPlan(request, logs, id) + + emitProgress(context, { + stage: 'queued', + message: intent === 'current-move' + ? '收到当前手分析任务。' + : intent === 'game-review' + ? '收到整盘复盘任务。' + : intent === 'batch-review' + ? '收到最近对局分析任务。' + : intent === 'training-plan' + ? '收到训练计划任务。' + : '收到开放式任务。' + }) + + try { + let result: TeacherRunResult + if (intent === 'current-move') { + result = await runCurrentMove(request, logs, id, context) + } else if (intent === 'game-review') { + result = await runGameReview(request, logs, id, context) + } else if (intent === 'batch-review') { + result = await runBatchReview(request, logs, id, context) + } else if (intent === 'training-plan') { + result = await runTrainingPlan(request, logs, id, context) + } else { + result = await runOpenEndedTask(request, logs, id, context) + } + emitProgress(context, { + stage: 'done', + markdown: result.markdown, + toolLogs: cloneToolLogs(logs), + result + }) + return result + } catch (error) { + emitProgress(context, { + stage: 'error', + error: String(error), + toolLogs: cloneToolLogs(logs) + }) + throw error } - return runOpenEndedTask(request, logs, id) } diff --git a/src/preload/index.ts b/src/preload/index.ts index a240b95..517b9e7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4,43 +4,106 @@ import type { AnalyzeGameQuickRequest, AnalyzeGameQuickProgress, AnalyzePositionRequest, + AnalyzePositionProgress, DashboardData, + FoxSyncResponse, FoxSyncRequest, - FoxSyncResult, GameRecord, + KataGoAssetInstallProgress, + KataGoAssetInstallRequest, + KataGoAssetInstallResult, + KataGoAssetStatus, + KataGoBenchmarkRequest, + KataGoBenchmarkResult, + LibraryImportResult, + LlmModelsListRequest, + LlmModelsListResult, LlmSettingsTestRequest, LlmSettingsTestResult, KataGoMoveAnalysis, ReviewRequest, ReviewResult, + StudentBindingSuggestion, + StudentProfile, + ReleaseReadinessResult, TeacherRunRequest, + TeacherRunProgress, TeacherRunResult } from '@main/lib/types' +import type { DiagnosticsReport } from '@main/services/diagnostics/types' +import type { KnowledgeSearchQuery, KnowledgeSearchResult } from '@main/services/knowledge/schema' + +export type DesktopCommand = + | 'open-command-palette' + | 'open-settings' + | 'import-sgf' + | 'analyze-current' + | 'analyze-game' + | 'analyze-recent' + | 'toggle-library' + | 'open-ui-gallery' const api = { getDashboard: (): Promise => ipcRenderer.invoke('dashboard:get'), getGameRecord: (gameId: string): Promise => ipcRenderer.invoke('library:record', gameId), - importLibrary: (): Promise => ipcRenderer.invoke('library:import'), + importLibrary: (): Promise => ipcRenderer.invoke('library:import'), updateSettings: (payload: Partial): Promise => ipcRenderer.invoke('settings:update', payload), autoDetectSettings: (): Promise => ipcRenderer.invoke('settings:auto-detect'), - syncFox: (payload: FoxSyncRequest): Promise<{ dashboard: DashboardData; result: FoxSyncResult }> => ipcRenderer.invoke('fox:sync', payload), + syncFox: (payload: FoxSyncRequest): Promise => ipcRenderer.invoke('fox:sync', payload), startReview: (payload: ReviewRequest): Promise => ipcRenderer.invoke('review:start', payload), analyzePosition: (payload: AnalyzePositionRequest): Promise => ipcRenderer.invoke('katago:analyze-position', payload), + analyzePositionStream: (payload: AnalyzePositionRequest): Promise => ipcRenderer.invoke('katago:analyze-position-stream', payload), analyzeGameQuick: (payload: AnalyzeGameQuickRequest): Promise => ipcRenderer.invoke('katago:analyze-game-quick', payload), + benchmarkKataGo: (payload?: KataGoBenchmarkRequest): Promise => ipcRenderer.invoke('katago:benchmark', payload ?? {}), + onAnalyzePositionProgress: (handler: (payload: AnalyzePositionProgress) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: AnalyzePositionProgress): void => handler(payload) + ipcRenderer.on('katago:analyze-position-progress', listener) + return () => ipcRenderer.removeListener('katago:analyze-position-progress', listener) + }, onAnalyzeGameQuickProgress: (handler: (payload: AnalyzeGameQuickProgress) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: AnalyzeGameQuickProgress): void => handler(payload) ipcRenderer.on('katago:analyze-game-quick-progress', listener) return () => ipcRenderer.removeListener('katago:analyze-game-quick-progress', listener) }, + getDiagnostics: (): Promise => ipcRenderer.invoke('diagnostics:get'), + inspectKataGoAssets: (): Promise => ipcRenderer.invoke('katago-assets:inspect'), + installKataGoOfficialModel: (payload: KataGoAssetInstallRequest): Promise => ipcRenderer.invoke('katago-assets:install-official-model', payload), + onKataGoAssetInstallProgress: (handler: (payload: KataGoAssetInstallProgress) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: KataGoAssetInstallProgress): void => handler(payload) + ipcRenderer.on('katago-assets:install-progress', listener) + return () => ipcRenderer.removeListener('katago-assets:install-progress', listener) + }, + listStudentProfiles: (): Promise => ipcRenderer.invoke('student:list'), + suggestStudentBindings: (payload: { blackName?: string; whiteName?: string; source?: string; foxNickname?: string }): Promise => ipcRenderer.invoke('student:suggest-bindings', payload), + bindSgfGameToStudent: (payload: { gameId: string; studentId?: string; createDisplayName?: string; aliasFromPlayerName?: string }): Promise => ipcRenderer.invoke('student:bind-sgf-game', payload), + bindFoxGamesToStudent: (payload: { foxNickname: string; gameIds: string[]; aliases?: string[] }): Promise => ipcRenderer.invoke('student:bind-fox-games', payload), + getStudentForGame: (gameId: string): Promise => ipcRenderer.invoke('student:for-game', gameId), + listStudents: (): Promise => ipcRenderer.invoke('students:list'), + resolveStudentByFoxNickname: (nickname: string): Promise => ipcRenderer.invoke('students:resolve-fox', nickname), + attachGameToStudent: (payload: { gameId: string; studentId: string }): Promise => ipcRenderer.invoke('students:attach-game', payload), + addStudentAlias: (payload: { studentId: string; alias: string }): Promise => ipcRenderer.invoke('students:alias', payload), + searchKnowledge: (payload: KnowledgeSearchQuery): Promise => ipcRenderer.invoke('knowledge:search', payload), runTeacherTask: (payload: TeacherRunRequest): Promise => ipcRenderer.invoke('teacher:run', payload), + onTeacherRunProgress: (handler: (payload: TeacherRunProgress) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: TeacherRunProgress): void => handler(payload) + ipcRenderer.on('teacher:run-progress', listener) + return () => ipcRenderer.removeListener('teacher:run-progress', listener) + }, testLlmSettings: (payload: LlmSettingsTestRequest): Promise => ipcRenderer.invoke('llm:test', payload), - openPath: (filePath: string): Promise => ipcRenderer.invoke('path:open', filePath) + listLlmModels: (payload: LlmModelsListRequest): Promise => ipcRenderer.invoke('llm:list-models', payload), + getReleaseReadiness: (): Promise => ipcRenderer.invoke('release:readiness'), + openPath: (filePath: string): Promise => ipcRenderer.invoke('path:open', filePath), + onDesktopCommand: (handler: (command: DesktopCommand) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, command: DesktopCommand): void => handler(command) + ipcRenderer.on('desktop:command', listener) + return () => ipcRenderer.removeListener('desktop:command', listener) + } } -contextBridge.exposeInMainWorld('katasensei', api) +contextBridge.exposeInMainWorld('gomentor', api) declare global { interface Window { - katasensei: typeof api + gomentor: typeof api } } diff --git a/src/renderer/index.html b/src/renderer/index.html index 600543d..b2e6620 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,7 +4,7 @@ - KataSensei + GoMentor diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 95fb4d7..3114bf4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,4 @@ -import type { FormEvent, PointerEvent, ReactElement } from 'react' +import type { FormEvent, KeyboardEvent, PointerEvent, ReactElement, ReactNode } from 'react' import { useEffect, useMemo, useRef, useState } from 'react' import type { AnalyzeGameQuickProgress, @@ -6,16 +6,38 @@ import type { GameMove, GameRecord, KataGoCandidate, + KataGoAssetInstallProgress, + KataGoAssetStatus, + KataGoBenchmarkResult, KataGoMoveAnalysis, KataGoModelPresetId, LibraryGame, StoneColor, + StudentBindingSuggestion, + StudentProfile, + ReleaseReadinessResult, + TeacherRunRequest, + TeacherRunProgress, TeacherRunResult } from '@main/lib/types' import lizzieBlackStoneUrl from './assets/lizzie/black.png' import lizzieBoardUrl from './assets/lizzie/board.png' import lizzieWhiteStoneUrl from './assets/lizzie/white.png' import logoUrl from '../../../assets/logo.svg' +import { GoBoardV2 } from './features/board/GoBoardV2' +import type { KeyMoveSummary } from './features/board/KeyMoveNavigator' +import { WinrateTimelineV2 } from './features/board/WinrateTimelineV2' +import { parseBoardPoint, type RenderKeyMove } from './features/board/boardGeometry' +import { DiagnosticsGate } from './features/diagnostics/DiagnosticsGate' +import { UiGallery } from './features/gallery/UiGallery' +import { BetaAcceptancePanel, type BetaAcceptanceItem } from './features/release/BetaAcceptancePanel' +import { StudentBindingDialog } from './features/student/StudentBindingDialog' +import { StudentRailCard } from './features/student/StudentRailCard' +import { KataGoAssetsPanel } from './features/settings/KataGoAssetsPanel' +import { TeacherComposerPro } from './features/teacher/TeacherComposerPro' +import './features/diagnostics/diagnostics.css' +import './features/student/student.css' +import './features/teacher/teacher-run-card.css' const emptyDashboard: DashboardData = { settings: { @@ -23,6 +45,13 @@ const emptyDashboard: DashboardData = { katagoConfig: '', katagoModel: '', katagoModelPreset: 'official-b18-recommended', + katagoAnalysisThreads: 0, + katagoSearchThreadsPerAnalysisThread: 1, + katagoMaxBatchSize: 32, + katagoCacheSizePowerOfTwo: 20, + katagoBenchmarkThreads: 0, + katagoBenchmarkVisitsPerSecond: 0, + katagoBenchmarkUpdatedAt: '', pythonBin: 'python3', llmBaseUrl: 'https://api.openai.com/v1', llmApiKey: '', @@ -51,18 +80,66 @@ type ChatMessage = { id: string role: 'student' | 'teacher' content: string + status?: 'running' | 'completed' | 'error' result?: TeacherRunResult + toolLogs?: TeacherRunResult['toolLogs'] } type EvaluationByMove = Record type StatusTone = 'good' | 'warn' | 'neutral' +type TimelineIssueColor = 'B' | 'W' interface StatusPill { label: string tone: StatusTone } +interface StudentBindingState { + game: LibraryGame + suggestions: StudentBindingSuggestion[] +} + +interface LiveAnalysisState { + running: boolean + status: string + visits: number + bestVisits: number + visitsPerSecond: number + targetMoveNumber: number | null + round: number +} + +interface TimelineIssueItem { + moveNumber: number + color: TimelineIssueColor + playedMove: string + bestMove: string + loss: number + severity: 'quiet' | 'inaccuracy' | 'mistake' | 'blunder' +} + +type DesktopCommand = + | 'open-command-palette' + | 'open-settings' + | 'import-sgf' + | 'analyze-current' + | 'analyze-game' + | 'analyze-recent' + | 'toggle-library' + | 'open-ui-gallery' + const letters = 'ABCDEFGHJKLMNOPQRSTUVWXYZ' +const LIVE_ANALYSIS_VISIT_STEPS = [24, 48, 80, 120, 180, 260, 380, 560, 820, 1200, 1800, 2600, 3800, 5200] +const LIVE_ANALYSIS_TOTAL_VISIT_LIMIT = 5200 +const LIVE_ANALYSIS_BEST_VISIT_LIMIT = 1800 +const LIVE_ANALYSIS_TIME_LIMIT_MS = 150_000 +const LIVE_ANALYSIS_REPORT_INTERVAL_SECONDS = 0.2 +const QUICK_GRAPH_FAST_VISITS = 25 +const QUICK_GRAPH_FAST_VISITS_STRONG = 40 +const QUICK_GRAPH_REFINE_VISITS = 120 +const QUICK_GRAPH_REFINE_TOP_N = 18 +const LIBRARY_PAGE_SIZE = 10 +const TIMELINE_ISSUE_MIN_LOSS = 1 function safePlayerName(name: string | undefined, fallback: string): string { const value = (name ?? '').trim() @@ -75,51 +152,231 @@ function gameDisplayName(game: LibraryGame): string { return `${black} vs ${white}` } +function boardGameTitle(game: LibraryGame): string { + const black = safePlayerName(game.black, '黑方') + const white = safePlayerName(game.white, '白方') + return `黑棋 ${black} vs 白棋 ${white}` +} + function boardCandidateMoves(analysis: KataGoMoveAnalysis | null): KataGoCandidate[] { if (!analysis) { return [] } - return analysis.after.topMoves.length > 0 ? analysis.after.topMoves : analysis.before.topMoves + return analysis.before.topMoves.length > 0 ? analysis.before.topMoves : analysis.after.topMoves } function analysisHasCandidates(analysis: KataGoMoveAnalysis | undefined | null): boolean { return Boolean(analysis && (analysis.before.topMoves.length > 0 || analysis.after.topMoves.length > 0)) } +function candidateVisitsTotal(analysis: KataGoMoveAnalysis | null | undefined): number { + if (!analysis) { + return 0 + } + const before = analysis.before.topMoves.reduce((total, candidate) => total + Math.max(0, Number(candidate.visits) || 0), 0) + const after = analysis.after.topMoves.reduce((total, candidate) => total + Math.max(0, Number(candidate.visits) || 0), 0) + return Math.max(before, after) +} + +function candidateBestVisits(analysis: KataGoMoveAnalysis | null | undefined): number { + return Math.max(0, Number(analysis?.before.topMoves[0]?.visits ?? analysis?.after.topMoves[0]?.visits) || 0) +} + +function normalizeLossPercent(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0 + } + return Math.max(0, Math.abs(value)) +} + +function quickGraphFastVisits(visitsPerSecond: number): number { + if (!Number.isFinite(visitsPerSecond) || visitsPerSecond <= 0) { + return QUICK_GRAPH_FAST_VISITS + } + return visitsPerSecond >= 450 ? QUICK_GRAPH_FAST_VISITS_STRONG : QUICK_GRAPH_FAST_VISITS +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function keyMoveSummariesFromEvaluations(evaluations: EvaluationByMove): KeyMoveSummary[] { + return Object.values(evaluations) + .flatMap((item) => { + const severity = evaluationSeverity(item) + if (severity === 'quiet') { + return [] + } + const best = item.before.topMoves[0] ?? item.after.topMoves[0] + const playedMove = item.playedMove?.move ?? item.currentMove?.gtp + const loss = item.playedMove?.winrateLoss ?? 0 + const scoreLoss = item.playedMove?.scoreLoss ?? 0 + return [{ + moveNumber: item.moveNumber, + color: item.currentMove?.color, + label: best && playedMove ? `${playedMove} -> ${best.move}` : playedMove ?? `第 ${item.moveNumber} 手`, + gtp: playedMove, + reason: `胜率损失 ${loss.toFixed(1)}%,目差损失 ${scoreLoss.toFixed(1)}。`, + winrateDrop: loss / 100, + scoreLoss, + severity + } satisfies KeyMoveSummary] + }) + .sort((left, right) => { + const leftLoss = Math.abs(left.winrateDrop ?? 0) + const rightLoss = Math.abs(right.winrateDrop ?? 0) + return rightLoss - leftLoss || left.moveNumber - right.moveNumber + }) + .slice(0, 8) +} + +function timelineIssuesFromEvaluations( + evaluations: EvaluationByMove, + record: GameRecord | null, + color: TimelineIssueColor +): TimelineIssueItem[] { + return Object.values(evaluations) + .flatMap((item) => { + const moveColor = item.currentMove?.color ?? record?.moves[item.moveNumber - 1]?.color + if (moveColor !== color) { + return [] + } + const loss = normalizeLossPercent(item.playedMove?.winrateLoss) + if (loss < TIMELINE_ISSUE_MIN_LOSS) { + return [] + } + const playedMove = item.playedMove?.move ?? item.currentMove?.gtp ?? record?.moves[item.moveNumber - 1]?.gtp ?? '' + const bestMove = item.before.topMoves[0]?.move ?? item.after.topMoves[0]?.move ?? '' + return [{ + moveNumber: item.moveNumber, + color: moveColor, + playedMove, + bestMove, + loss, + severity: evaluationSeverity(item) + } satisfies TimelineIssueItem] + }) + .sort((left, right) => right.loss - left.loss || left.moveNumber - right.moveNumber) +} + +function keyMoveMarksFromSummaries( + summaries: KeyMoveSummary[], + evaluations: EvaluationByMove, + boardSize: number +): RenderKeyMove[] { + return summaries.flatMap((summary) => { + const item = evaluations[summary.moveNumber] + const point = parseBoardPoint(item?.currentMove ?? item?.playedMove?.move ?? summary.gtp, boardSize) + if (!point) { + return [] + } + const severity = !summary.severity || summary.severity === 'quiet' ? 'turning-point' : summary.severity + return [{ + ...point, + moveNumber: summary.moveNumber, + severity, + label: String(summary.moveNumber) + } satisfies RenderKeyMove] + }) +} + +function shouldOpenUiGallery(): boolean { + const search = new URLSearchParams(window.location.search) + return search.has('ui-gallery') || window.location.hash === '#/ui-gallery' +} + export function App(): ReactElement { + if (shouldOpenUiGallery()) { + return + } + const [dashboard, setDashboard] = useState(emptyDashboard) const [selectedId, setSelectedId] = useState('') const [record, setRecord] = useState(null) const [moveNumber, setMoveNumber] = useState(0) const [analysis, setAnalysis] = useState(null) const [evaluations, setEvaluations] = useState({}) + const [timelineIssueColor, setTimelineIssueColor] = useState('B') const [foxKeyword, setFoxKeyword] = useState('') const [playerName, setPlayerName] = useState('') const [prompt, setPrompt] = useState('') const [busy, setBusy] = useState('') const [graphBusy, setGraphBusy] = useState(false) const [graphProgress, setGraphProgress] = useState('') + const [liveAnalysis, setLiveAnalysis] = useState({ + running: false, + status: '已暂停', + visits: 0, + bestVisits: 0, + visitsPerSecond: 0, + targetMoveNumber: null, + round: 0 + }) const [error, setError] = useState('') const [settingsOpen, setSettingsOpen] = useState(false) + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false) const [libraryCollapsed, setLibraryCollapsed] = useState(false) const [llmTestMessage, setLlmTestMessage] = useState('') + const [katagoBenchmark, setKataGoBenchmark] = useState(null) + const [katagoBenchmarkMessage, setKataGoBenchmarkMessage] = useState('') + const [katagoInstallMessage, setKataGoInstallMessage] = useState('') + const [katagoInstallProgress, setKataGoInstallProgress] = useState(null) + const [currentStudent, setCurrentStudent] = useState(null) + const [studentBinding, setStudentBinding] = useState(null) + const [katagoAssets, setKatagoAssets] = useState(null) const graphRunId = useRef('') - const [messages, setMessages] = useState([ - { - id: 'hello', - role: 'teacher', - content: '我会像围棋老师智能体一样工作:看棋盘、读 KataGo、查知识库、记住学生画像。' - } - ]) + const liveAnalysisRunId = useRef('') + const autoAnalysisRequestId = useRef('') + const userPausedLiveAnalysisRef = useRef(false) + const moveNumberRef = useRef(moveNumber) + const selectedGameIdRef = useRef('') + const [messages, setMessages] = useState([]) useEffect(() => { void refresh() + void refreshKataGoAssets() + }, []) + + useEffect(() => { + return window.gomentor.onKataGoAssetInstallProgress((progress) => { + setKataGoInstallProgress(progress) + setKataGoInstallMessage(progress.message) + }) }, []) const selectedGame = useMemo( - () => dashboard.games.find((game) => game.id === selectedId) ?? dashboard.games[0], + () => { + const exact = dashboard.games.find((game) => game.id === selectedId) + if (exact) { + return exact + } + if (!selectedId) { + return dashboard.games.find((game) => game.source !== 'fox' || game.downloadStatus === 'downloaded') + } + return dashboard.games[0] + }, [dashboard.games, selectedId] ) + const keyMoveSummaries = useMemo(() => keyMoveSummariesFromEvaluations(evaluations), [evaluations]) + const timelineIssues = useMemo( + () => timelineIssuesFromEvaluations(evaluations, record, timelineIssueColor), + [evaluations, record, timelineIssueColor] + ) + const boardKeyMoveMarks = useMemo( + () => keyMoveMarksFromSummaries(keyMoveSummaries, evaluations, record?.boardSize ?? 19), + [keyMoveSummaries, evaluations, record?.boardSize] + ) + const currentBoardKeyMoveMarks = useMemo( + () => boardKeyMoveMarks.filter((mark) => mark.moveNumber === moveNumber), + [boardKeyMoveMarks, moveNumber] + ) + const currentAnalysis = useMemo(() => { + const cached = evaluations[moveNumber] ?? null + if (analysis?.moveNumber === moveNumber && (analysisHasCandidates(analysis) || !analysisHasCandidates(cached))) { + return analysis + } + return cached + }, [analysis, evaluations, moveNumber]) useEffect(() => { if (selectedGame && !selectedId) { @@ -127,17 +384,49 @@ export function App(): ReactElement { } }, [selectedGame, selectedId]) + useEffect(() => { + moveNumberRef.current = moveNumber + }, [moveNumber]) + + useEffect(() => { + selectedGameIdRef.current = selectedGame?.id ?? '' + }, [selectedGame?.id]) + useEffect(() => { if (!selectedGame) { setRecord(null) + setCurrentStudent(null) return } + pauseLiveAnalysis('切换棋谱,准备精读') + void loadBoundPlayer(selectedGame.id) void loadRecord(selectedGame.id) }, [selectedGame?.id]) + useEffect(() => { + const dispose = window.gomentor.onDesktopCommand?.((command) => runDesktopCommand(command)) + return () => dispose?.() + }, [selectedGame?.id, moveNumber, busy, record, dashboard.games.length]) + + useEffect(() => { + function handleKeyDown(event: globalThis.KeyboardEvent): void { + const key = event.key.toLowerCase() + if ((event.metaKey || event.ctrlKey) && key === 'k') { + event.preventDefault() + setCommandPaletteOpen(true) + } + if (event.key === 'Escape') { + setCommandPaletteOpen(false) + setSettingsOpen(false) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + async function refresh(): Promise { try { - const next = await window.katasensei.getDashboard() + const next = await window.gomentor.getDashboard() setDashboard(next) if (!playerName && next.settings.defaultPlayerName) { setPlayerName(next.settings.defaultPlayerName) @@ -147,25 +436,53 @@ export function App(): ReactElement { } } + async function refreshKataGoAssets(): Promise { + try { + setKatagoAssets(await window.gomentor.inspectKataGoAssets()) + } catch (cause) { + setError(`KataGo 资源检查失败: ${String(cause)}`) + } + } + async function loadRecord(gameId: string): Promise { try { - const next = await window.katasensei.getGameRecord(gameId) + const next = await window.gomentor.getGameRecord(gameId) + setDashboard((current) => ({ + ...current, + games: current.games.map((game) => (game.id === next.game.id ? next.game : game)) + })) + userPausedLiveAnalysisRef.current = false setRecord(next) setMoveNumber(next.moves.length) setAnalysis(null) setEvaluations({}) void warmupEvaluationGraph(gameId, next.moves.length) + queueAutoLiveAnalysis(gameId, next, next.moves.length, 120) } catch (cause) { setError(String(cause)) } } + async function loadBoundPlayer(gameId: string): Promise { + try { + const student = await window.gomentor.getStudentForGame(gameId) + if (selectedGameIdRef.current === gameId) { + setCurrentStudent(student) + } + } catch (cause) { + if (selectedGameIdRef.current === gameId) { + setCurrentStudent(null) + } + setError(`棋手绑定读取失败: ${String(cause)}`) + } + } + async function warmupEvaluationGraph(gameId: string, defaultMoveNumber: number): Promise { const runId = crypto.randomUUID() graphRunId.current = runId setGraphBusy(true) setGraphProgress('启动快速胜率图') - const disposeProgress = window.katasensei.onAnalyzeGameQuickProgress((progress: AnalyzeGameQuickProgress) => { + const disposeProgress = window.gomentor.onAnalyzeGameQuickProgress((progress: AnalyzeGameQuickProgress) => { if (graphRunId.current !== runId || progress.runId !== runId || progress.gameId !== gameId) { return } @@ -177,9 +494,12 @@ export function App(): ReactElement { } }) try { - const quickEvaluations = await window.katasensei.analyzeGameQuick({ + const fastVisits = quickGraphFastVisits(dashboard.settings.katagoBenchmarkVisitsPerSecond) + const quickEvaluations = await window.gomentor.analyzeGameQuick({ gameId, - maxVisits: 12, + maxVisits: fastVisits, + refineVisits: Math.max(QUICK_GRAPH_REFINE_VISITS, fastVisits * 3), + refineTopN: QUICK_GRAPH_REFINE_TOP_N, runId }) if (graphRunId.current !== runId) { @@ -214,9 +534,12 @@ export function App(): ReactElement { setBusy('import') setError('') try { - const next = await window.katasensei.importLibrary() + const { dashboard: next, imported } = await window.gomentor.importLibrary() setDashboard(next) - if (next.games[0]) { + if (imported[0]) { + setSelectedId(imported[0].id) + void openStudentBinding(imported[0]) + } else if (next.games[0]) { setSelectedId(next.games[0].id) } } catch (cause) { @@ -230,15 +553,14 @@ export function App(): ReactElement { setBusy('fox') setError('') try { - const { dashboard: next, result } = await window.katasensei.syncFox({ + const { dashboard: next, result, student } = await window.gomentor.syncFox({ keyword: foxKeyword }) setDashboard(next) + setCurrentStudent(student ?? null) setFoxKeyword(result.nickname) - if (result.saved[0]) { - setSelectedId(result.saved[0].id) - } else if (next.games[0]) { - setSelectedId(next.games[0].id) + if (selectedId && !next.games.some((game) => game.id === selectedId)) { + setSelectedId('') } } catch (cause) { setError(String(cause)) @@ -247,12 +569,66 @@ export function App(): ReactElement { } } + async function openStudentBinding(game: LibraryGame): Promise { + try { + const suggestions = await window.gomentor.suggestStudentBindings({ + blackName: game.black, + whiteName: game.white, + source: game.source, + foxNickname: game.source === 'fox' ? (foxKeyword || game.sourceLabel.replace(/^Fox\s*/i, '')) : undefined + }) + setStudentBinding({ game, suggestions }) + } catch (cause) { + setError(`棋手绑定建议生成失败: ${String(cause)}`) + } + } + + async function bindImportedGameToExisting(input: { studentId: string; aliasFromPlayerName?: string }): Promise { + if (!studentBinding) { + return + } + try { + const student = await window.gomentor.bindSgfGameToStudent({ + gameId: studentBinding.game.id, + studentId: input.studentId, + aliasFromPlayerName: input.aliasFromPlayerName + }) + setCurrentStudent(student) + setStudentBinding(null) + } catch (cause) { + setError(`绑定棋手失败: ${String(cause)}`) + } + } + + async function createStudentAndBind(input: { displayName: string; foxNickname?: string; aliasFromPlayerName?: string }): Promise { + if (!studentBinding) { + return + } + try { + const student = input.foxNickname + ? await window.gomentor.bindFoxGamesToStudent({ + foxNickname: input.foxNickname, + gameIds: [studentBinding.game.id], + aliases: [input.displayName, input.aliasFromPlayerName ?? ''].filter(Boolean) + }) + : await window.gomentor.bindSgfGameToStudent({ + gameId: studentBinding.game.id, + createDisplayName: input.displayName, + aliasFromPlayerName: input.aliasFromPlayerName + }) + setCurrentStudent(student) + setStudentBinding(null) + } catch (cause) { + setError(`创建棋手画像失败: ${String(cause)}`) + } + } + async function saveSettings(form: HTMLFormElement): Promise { setBusy('settings') setError('') try { const formData = new FormData(form) - const next = await window.katasensei.updateSettings({ + const next = await window.gomentor.updateSettings({ katagoModelPreset: String(formData.get('katagoModelPreset') ?? dashboard.settings.katagoModelPreset) as KataGoModelPresetId, llmBaseUrl: String(formData.get('llmBaseUrl') ?? ''), llmApiKey: String(formData.get('llmApiKey') ?? ''), @@ -260,6 +636,7 @@ export function App(): ReactElement { }) setDashboard(next) setLlmTestMessage('配置已保存') + void refreshKataGoAssets() if (selectedGame && record) { setAnalysis(null) setEvaluations({}) @@ -277,7 +654,7 @@ export function App(): ReactElement { setLlmTestMessage('') try { const formData = new FormData(form) - const result = await window.katasensei.testLlmSettings({ + const result = await window.gomentor.testLlmSettings({ llmBaseUrl: String(formData.get('llmBaseUrl') ?? ''), llmApiKey: String(formData.get('llmApiKey') ?? ''), llmModel: String(formData.get('llmModel') ?? '') @@ -290,8 +667,145 @@ export function App(): ReactElement { } } - function appendMessage(message: Omit): void { - setMessages((current) => [...current, { ...message, id: crypto.randomUUID() }]) + async function runKataGoBenchmark(): Promise { + setBusy('katago-benchmark') + setKataGoBenchmarkMessage('正在调用 KataGo 官方 benchmark,通常需要几十秒。') + setError('') + try { + if (typeof window.gomentor.benchmarkKataGo !== 'function') { + throw new Error('测速服务尚未加载,请重启应用后再试。') + } + const result = await window.gomentor.benchmarkKataGo() + setKataGoBenchmark(result) + setKataGoBenchmarkMessage(`已优化:推荐 ${result.recommendedThreads} 线程,${formatSearchSpeed(result.visitsPerSecond)}。`) + setDashboard(await window.gomentor.getDashboard()) + void refreshKataGoAssets() + if (selectedGame && record) { + pauseLiveAnalysis('测速完成,准备使用新配置') + setAnalysis(null) + setEvaluations({}) + void warmupEvaluationGraph(selectedGame.id, moveNumber) + if (!userPausedLiveAnalysisRef.current) { + void startLiveAnalysis() + } + } + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause) + setKataGoBenchmarkMessage(`KataGo 测速失败:${message}`) + } finally { + setBusy('') + } + } + + async function installOfficialKataGoModel(presetId: KataGoModelPresetId): Promise { + setBusy('katago-install') + setError('') + setKataGoInstallProgress({ stage: 'discovering', message: '正在准备 KataGo 官方权重安装。' }) + setKataGoInstallMessage('正在准备 KataGo 官方权重安装。') + try { + const result = await window.gomentor.installKataGoOfficialModel({ presetId }) + setKataGoInstallMessage(result.detail) + const next = await window.gomentor.updateSettings({ katagoModelPreset: presetId }) + setDashboard(next) + await refreshKataGoAssets() + if (selectedGame && record) { + pauseLiveAnalysis('KataGo 权重已更新,准备重新分析') + setAnalysis(null) + setEvaluations({}) + void warmupEvaluationGraph(selectedGame.id, moveNumber) + if (!userPausedLiveAnalysisRef.current) { + void startLiveAnalysis() + } + } + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause) + setKataGoInstallMessage(`KataGo 官方权重安装失败:${message}`) + setKataGoInstallProgress({ stage: 'error', message }) + } finally { + setBusy('') + } + } + + function appendMessage(message: Omit): string { + const id = crypto.randomUUID() + setMessages((current) => [...current, { ...message, id }]) + return id + } + + function updateMessage(messageId: string, updater: (message: ChatMessage) => ChatMessage): void { + setMessages((current) => current.map((message) => (message.id === messageId ? updater(message) : message))) + } + + function mergeTeacherProgress(messageId: string, progress: TeacherRunProgress): void { + updateMessage(messageId, (message) => { + if (message.role !== 'teacher') { + return message + } + if (progress.stage === 'assistant-delta') { + return { + ...message, + status: 'running', + content: `${message.content}${progress.markdownDelta ?? ''}` + } + } + if (progress.stage === 'done' && progress.result) { + return { + ...message, + status: 'completed', + content: progress.result.markdown, + result: progress.result, + toolLogs: progress.result.toolLogs + } + } + if (progress.stage === 'error') { + return { + ...message, + status: 'error', + content: message.content || `任务失败:${progress.error ?? '未知错误'}`, + toolLogs: progress.toolLogs ?? message.toolLogs + } + } + return { + ...message, + status: message.status ?? 'running', + toolLogs: progress.toolLogs ?? message.toolLogs + } + }) + } + + async function runTeacherTaskWithStream( + request: Omit, + assistantMessageId: string + ): Promise { + const runId = crypto.randomUUID() + const dispose = window.gomentor.onTeacherRunProgress((progress) => { + if (progress.runId === runId) { + mergeTeacherProgress(assistantMessageId, progress) + } + }) + try { + const result = await window.gomentor.runTeacherTask({ + ...request, + runId + }) + updateMessage(assistantMessageId, (message) => ({ + ...message, + status: 'completed', + content: result.markdown, + result, + toolLogs: result.toolLogs + })) + return result + } catch (cause) { + updateMessage(assistantMessageId, (message) => ({ + ...message, + status: 'error', + content: message.content || `任务失败:${String(cause)}` + })) + throw cause + } finally { + dispose() + } } function rememberEvaluation(nextAnalysis: KataGoMoveAnalysis): void { @@ -303,48 +817,375 @@ export function App(): ReactElement { })) } + function analysisForMove(targetMove: number): KataGoMoveAnalysis | null { + const cached = evaluations[targetMove] ?? null + if (analysis?.moveNumber === targetMove && (analysisHasCandidates(analysis) || !analysisHasCandidates(cached))) { + return analysis + } + return cached + } + + function queueAutoLiveAnalysis(gameId: string, targetRecord: GameRecord, targetMove: number, delay = 80): void { + if (userPausedLiveAnalysisRef.current) { + return + } + const requestId = crypto.randomUUID() + autoAnalysisRequestId.current = requestId + window.setTimeout(() => { + if ( + autoAnalysisRequestId.current !== requestId || + selectedGameIdRef.current !== gameId || + userPausedLiveAnalysisRef.current + ) { + return + } + void startLiveAnalysis({ + gameId, + record: targetRecord, + moveNumber: targetMove, + manual: false + }) + }, delay) + } + function jumpToMove(next: number): void { - setMoveNumber(next) - setAnalysis(evaluations[next] ?? null) + const targetMove = record ? Math.max(0, Math.min(record.moves.length, Math.round(next))) : next + if (liveAnalysis.running) { + pauseLiveAnalysis('切换手数,准备继续分析') + } + setMoveNumber(targetMove) + setAnalysis(analysisForMove(targetMove)) + if (record && selectedGame && !userPausedLiveAnalysisRef.current) { + queueAutoLiveAnalysis(selectedGame.id, record, targetMove) + } } - async function runCurrentMoveAnalysis(): Promise { - if (!record || !selectedGame) { + function pauseLiveAnalysis(message = '已暂停精读', manual = false): void { + if (manual) { + userPausedLiveAnalysisRef.current = true + } + autoAnalysisRequestId.current = crypto.randomUUID() + liveAnalysisRunId.current = crypto.randomUUID() + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: message, + visitsPerSecond: 0 + })) + } + + async function startLiveAnalysis(options: { + gameId?: string + record?: GameRecord + moveNumber?: number + manual?: boolean + } = {}): Promise { + const targetRecord = options.record ?? record + const gameId = options.gameId ?? selectedGame?.id + if (!targetRecord || !gameId) { + return + } + if (options.manual !== false) { + userPausedLiveAnalysisRef.current = false + } + const targetMove = Math.max(0, Math.min(targetRecord.moves.length, Math.round(options.moveNumber ?? moveNumber))) + if ( + liveAnalysis.running && + liveAnalysis.targetMoveNumber === targetMove && + selectedGameIdRef.current === gameId + ) { + return + } + const runId = crypto.randomUUID() + const startedAt = Date.now() + let lastSampleAt = performance.now() + const cachedAnalysis = options.record ? null : analysisForMove(targetMove) + let lastVisitSample = candidateVisitsTotal(cachedAnalysis) + let lastEffectiveVisitSample = lastVisitSample + const benchmarkSpeed = dashboard.settings.katagoBenchmarkVisitsPerSecond + let lastSpeedSample = benchmarkSpeed + liveAnalysisRunId.current = runId + setError('') + setMoveNumber(targetMove) + setAnalysis(cachedAnalysis) + setLiveAnalysis({ + running: true, + status: `精读第 ${targetMove} 手`, + visits: lastVisitSample, + bestVisits: candidateBestVisits(cachedAnalysis), + visitsPerSecond: benchmarkSpeed, + targetMoveNumber: targetMove, + round: 0 + }) + + if (typeof window.gomentor.analyzePositionStream === 'function') { + const disposeProgress = window.gomentor.onAnalyzePositionProgress((progress) => { + if ( + liveAnalysisRunId.current !== runId || + progress.runId !== runId || + progress.gameId !== gameId || + progress.moveNumber !== targetMove || + selectedGameIdRef.current !== gameId + ) { + return + } + const nextAnalysis = progress.analysis + const totalVisits = candidateVisitsTotal(nextAnalysis) + const bestVisits = candidateBestVisits(nextAnalysis) + const sampledAt = performance.now() + const sampleSeconds = Math.max(0.1, (sampledAt - lastSampleAt) / 1000) + const visitsDelta = Math.max(0, totalVisits - lastVisitSample) + const measuredSpeed = visitsDelta > 0 ? visitsDelta / sampleSeconds : lastSpeedSample + lastSpeedSample = measuredSpeed > 0 ? Math.max(lastSpeedSample, measuredSpeed) : lastSpeedSample + const visitsPerSecond = lastSpeedSample || measuredSpeed + lastVisitSample = Math.max(lastVisitSample, totalVisits) + lastSampleAt = sampledAt + rememberEvaluation(nextAnalysis) + if (moveNumberRef.current === targetMove) { + setAnalysis(nextAnalysis) + } + setLiveAnalysis({ + running: !progress.isFinal, + status: progress.isFinal + ? `已完成 ${formatVisits(totalVisits)}` + : `实时搜索 ${formatVisits(totalVisits)} · 一选 ${formatVisits(bestVisits)}`, + visits: totalVisits, + bestVisits, + visitsPerSecond, + targetMoveNumber: targetMove, + round: 1 + }) + }) + try { + const finalAnalysis = await window.gomentor.analyzePositionStream({ + gameId, + moveNumber: targetMove, + maxVisits: LIVE_ANALYSIS_TOTAL_VISIT_LIMIT, + runId, + reportDuringSearchEvery: LIVE_ANALYSIS_REPORT_INTERVAL_SECONDS + }) + if (liveAnalysisRunId.current !== runId || selectedGameIdRef.current !== gameId) { + return + } + const totalVisits = candidateVisitsTotal(finalAnalysis) + const bestVisits = candidateBestVisits(finalAnalysis) + rememberEvaluation(finalAnalysis) + if (moveNumberRef.current === targetMove) { + setAnalysis(finalAnalysis) + } + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: `已完成 ${formatVisits(totalVisits)}`, + visits: totalVisits, + bestVisits, + targetMoveNumber: targetMove + })) + } catch (cause) { + if (liveAnalysisRunId.current === runId) { + const message = String(cause) + const hasUsablePartial = lastVisitSample > 0 || analysisHasCandidates(cachedAnalysis) + if (message.includes('KataGo 分析超时') && hasUsablePartial) { + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: `已保留 ${formatVisits(current.visits || lastVisitSample)}` + })) + } else if (message.includes('KataGo 分析超时')) { + try { + const quickAnalysis = await window.gomentor.analyzePosition({ + gameId, + moveNumber: targetMove, + maxVisits: 120 + }) + if (liveAnalysisRunId.current !== runId || selectedGameIdRef.current !== gameId) { + return + } + const totalVisits = candidateVisitsTotal(quickAnalysis) + const bestVisits = candidateBestVisits(quickAnalysis) + rememberEvaluation(quickAnalysis) + if (moveNumberRef.current === targetMove) { + setAnalysis(quickAnalysis) + } + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: `快速分析 ${formatVisits(totalVisits)}`, + visits: totalVisits, + bestVisits, + targetMoveNumber: targetMove + })) + } catch (fallbackCause) { + setError(`KataGo 暂时没有返回分析,请稍后重试或先运行一键测速。${String(fallbackCause)}`) + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: '等待重试' + })) + } + } else { + setError(`KataGo 实时分析失败: ${message}`) + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: '实时分析失败' + })) + } + } + } finally { + disposeProgress() + } return } + + for (const [index, maxVisits] of LIVE_ANALYSIS_VISIT_STEPS.entries()) { + if (liveAnalysisRunId.current !== runId) { + return + } + setLiveAnalysis((current) => ({ + ...current, + running: true, + status: `KataGo 精读中 · 上限 ${formatVisits(maxVisits)} visits`, + round: index + 1, + targetMoveNumber: targetMove + })) + try { + const nextAnalysis = await window.gomentor.analyzePosition({ + gameId, + moveNumber: targetMove, + maxVisits + }) + if (liveAnalysisRunId.current !== runId || selectedGameIdRef.current !== gameId) { + return + } + const totalVisits = candidateVisitsTotal(nextAnalysis) + const bestVisits = candidateBestVisits(nextAnalysis) + const sampledAt = performance.now() + const sampleSeconds = Math.max(0.1, (sampledAt - lastSampleAt) / 1000) + const effectiveVisits = Math.max(totalVisits, maxVisits) + const visitsDelta = Math.max(0, effectiveVisits - lastEffectiveVisitSample) + const visitsPerSecond = visitsDelta > 0 + ? visitsDelta / sampleSeconds + : (benchmarkSpeed || totalVisits / Math.max(0.1, (Date.now() - startedAt) / 1000)) + lastVisitSample = totalVisits + lastEffectiveVisitSample = effectiveVisits + lastSampleAt = sampledAt + rememberEvaluation(nextAnalysis) + if (moveNumberRef.current === targetMove) { + setAnalysis(nextAnalysis) + } + setLiveAnalysis({ + running: true, + status: `已搜索 ${formatVisits(totalVisits)} · 一选 ${formatVisits(bestVisits)}`, + visits: totalVisits, + bestVisits, + visitsPerSecond, + targetMoveNumber: targetMove, + round: index + 1 + }) + const elapsed = Date.now() - startedAt + const reachedTotal = totalVisits >= LIVE_ANALYSIS_TOTAL_VISIT_LIMIT + const reachedBest = bestVisits >= LIVE_ANALYSIS_BEST_VISIT_LIMIT + const reachedTime = elapsed >= LIVE_ANALYSIS_TIME_LIMIT_MS + if (reachedTotal || reachedBest || reachedTime) { + setLiveAnalysis({ + running: false, + status: reachedBest + ? `已达到一选 ${formatVisits(bestVisits)}` + : reachedTotal + ? `已达到总搜索 ${formatVisits(totalVisits)}` + : `已运行 ${Math.round(elapsed / 1000)} 秒`, + visits: totalVisits, + bestVisits, + visitsPerSecond, + targetMoveNumber: targetMove, + round: index + 1 + }) + return + } + } catch (cause) { + if (liveAnalysisRunId.current === runId) { + setError(`KataGo 精读失败: ${String(cause)}`) + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: '精读失败' + })) + } + return + } + await sleep(40) + } + + if (liveAnalysisRunId.current === runId) { + setLiveAnalysis((current) => ({ + ...current, + running: false, + status: `已完成 ${formatVisits(current.visits)}` + })) + } + } + + async function runMoveAnalysisAt(targetMoveNumber: number): Promise { + if (!record || !selectedGame || busy !== '') { + return + } + const targetMove = Math.max(0, Math.min(record.moves.length, Math.round(targetMoveNumber))) + if (liveAnalysis.running) { + pauseLiveAnalysis('老师讲解中,暂停精读') + } + setMoveNumber(targetMove) + setAnalysis(evaluations[targetMove] ?? null) setBusy('teacher') setError('') - const ask = `分析第 ${moveNumber} 手` + const ask = `分析第 ${targetMove} 手` appendMessage({ role: 'student', content: ask }) + const assistantMessageId = appendMessage({ role: 'teacher', content: '', status: 'running', toolLogs: [] }) try { - const nextAnalysis = await window.katasensei.analyzePosition({ + const nextAnalysis = await window.gomentor.analyzePosition({ gameId: selectedGame.id, - moveNumber, + moveNumber: targetMove, maxVisits: 520 }) setAnalysis(nextAnalysis) rememberEvaluation(nextAnalysis) - const boardImageDataUrl = await renderBoardPng(record, moveNumber, nextAnalysis) - const result = await window.katasensei.runTeacherTask({ + const boardImageDataUrl = await renderBoardPng(record, targetMove, nextAnalysis) + const result = await runTeacherTaskWithStream({ mode: 'current-move', prompt: ask, gameId: selectedGame.id, - moveNumber, + moveNumber: targetMove, playerName, boardImageDataUrl, prefetchedAnalysis: nextAnalysis - }) + }, assistantMessageId) const finalAnalysis = result.analysis ?? nextAnalysis setAnalysis(finalAnalysis) rememberEvaluation(finalAnalysis) - appendMessage({ role: 'teacher', content: result.markdown, result }) } catch (cause) { - appendMessage({ role: 'teacher', content: `任务失败:${String(cause)}` }) + updateMessage(assistantMessageId, (message) => ({ + ...message, + status: 'error', + content: message.content || `任务失败:${String(cause)}` + })) } finally { + setLiveAnalysis((current) => current.status === '老师讲解中,暂停精读' + ? { + ...current, + running: false, + status: '讲解完成,已暂停精读', + visitsPerSecond: 0 + } + : current + ) setBusy('') } } + async function runCurrentMoveAnalysis(): Promise { + await runMoveAnalysisAt(moveNumber) + } + async function runTeacherQuickTask(text: string): Promise { if (busy !== '') { return @@ -352,26 +1193,61 @@ export function App(): ReactElement { setBusy('teacher') setError('') appendMessage({ role: 'student', content: text }) + const assistantMessageId = appendMessage({ role: 'teacher', content: '', status: 'running', toolLogs: [] }) try { - const result = await window.katasensei.runTeacherTask({ + const result = await runTeacherTaskWithStream({ mode: 'freeform', prompt: text, gameId: selectedGame?.id, moveNumber, playerName - }) + }, assistantMessageId) if (result.analysis) { setAnalysis(result.analysis) rememberEvaluation(result.analysis) } - appendMessage({ role: 'teacher', content: result.markdown, result }) } catch (cause) { - appendMessage({ role: 'teacher', content: `任务失败:${String(cause)}` }) + updateMessage(assistantMessageId, (message) => ({ + ...message, + status: 'error', + content: message.content || `任务失败:${String(cause)}` + })) } finally { setBusy('') } } + function runDesktopCommand(command: DesktopCommand): void { + setCommandPaletteOpen(false) + switch (command) { + case 'open-command-palette': + setCommandPaletteOpen(true) + break + case 'open-settings': + setSettingsOpen(true) + break + case 'import-sgf': + void importSgf() + break + case 'analyze-current': + void runCurrentMoveAnalysis() + break + case 'analyze-game': + void runTeacherQuickTask('分析这盘整盘围棋,找出关键问题手、胜负转折点和复盘重点。') + break + case 'analyze-recent': + void runTeacherQuickTask('分析当前棋手最近10局围棋,找出常见问题、薄弱环节,并更新棋手画像。') + break + case 'toggle-library': + setLibraryCollapsed((value) => !value) + break + case 'open-ui-gallery': + window.location.hash = '#/ui-gallery' + window.location.reload() + break + } + } + async function sendTeacherPrompt(event: FormEvent): Promise { event.preventDefault() const text = prompt.trim() @@ -380,11 +1256,12 @@ export function App(): ReactElement { } setPrompt('') appendMessage({ role: 'student', content: text }) + const assistantMessageId = appendMessage({ role: 'teacher', content: '', status: 'running', toolLogs: [] }) setBusy('teacher') try { const wantsCurrentMove = /当前手|这手|这一手|本手/.test(text) if (wantsCurrentMove && record && selectedGame) { - const nextAnalysis = await window.katasensei.analyzePosition({ + const nextAnalysis = await window.gomentor.analyzePosition({ gameId: selectedGame.id, moveNumber, maxVisits: 520 @@ -392,7 +1269,7 @@ export function App(): ReactElement { setAnalysis(nextAnalysis) rememberEvaluation(nextAnalysis) const boardImageDataUrl = await renderBoardPng(record, moveNumber, nextAnalysis) - const result = await window.katasensei.runTeacherTask({ + const result = await runTeacherTaskWithStream({ mode: 'current-move', prompt: text, gameId: selectedGame.id, @@ -400,24 +1277,26 @@ export function App(): ReactElement { playerName, boardImageDataUrl, prefetchedAnalysis: nextAnalysis - }) + }, assistantMessageId) if (result.analysis) { setAnalysis(result.analysis) rememberEvaluation(result.analysis) } - appendMessage({ role: 'teacher', content: result.markdown, result }) } else { - const result = await window.katasensei.runTeacherTask({ + await runTeacherTaskWithStream({ mode: 'freeform', prompt: text, gameId: selectedGame?.id, moveNumber, playerName - }) - appendMessage({ role: 'teacher', content: result.markdown, result }) + }, assistantMessageId) } } catch (cause) { - appendMessage({ role: 'teacher', content: `任务失败:${String(cause)}` }) + updateMessage(assistantMessageId, (message) => ({ + ...message, + status: 'error', + content: message.content || `任务失败:${String(cause)}` + })) } finally { setBusy('') } @@ -431,102 +1310,178 @@ export function App(): ReactElement { { label: dashboard.systemProfile.hasLlmApiKey ? 'LLM 就绪' : 'LLM 未配置', tone: dashboard.systemProfile.hasLlmApiKey ? 'good' : 'warn' - }, - { - label: `${dashboard.games.length} 棋谱`, - tone: 'neutral' } ] return ( -
- -
+ + ({ + ...suggestion.student, + ...(suggestion.color ? { suggestedColor: suggestion.color } : {}) + }))} + onClose={() => setStudentBinding(null)} + onSkip={() => setStudentBinding(null)} + onBindExisting={(input) => void bindImportedGameToExisting(input)} + onCreateStudent={(input) => void createStudentAndBind(input)} + /> + ) } @@ -535,22 +1490,25 @@ function LibraryPanel({ selectedGame, foxKeyword, busy, + currentStudent, onSelect, - onImport, onSync, - onFoxKeyword + onImport, + onFoxKeyword, + onChangePlayerBinding }: { dashboard: DashboardData selectedGame?: LibraryGame foxKeyword: string busy: string + currentStudent: StudentProfile | null onSelect: (id: string) => void - onImport: () => void onSync: () => void + onImport: () => void onFoxKeyword: (value: string) => void + onChangePlayerBinding: () => void }): ReactElement { const [page, setPage] = useState(1) - const pageSize = 10 const keyword = foxKeyword.trim().toLowerCase() const visibleGames = useMemo(() => { if (!keyword) { @@ -568,9 +1526,11 @@ function LibraryPanel({ return haystack.includes(keyword) }) }, [dashboard.games, keyword]) - const pageCount = Math.max(1, Math.ceil(visibleGames.length / pageSize)) + const pageCount = Math.max(1, Math.ceil(visibleGames.length / LIBRARY_PAGE_SIZE)) const safePage = Math.min(page, pageCount) - const pageGames = visibleGames.slice((safePage - 1) * pageSize, safePage * pageSize) + const pageGames = visibleGames.slice((safePage - 1) * LIBRARY_PAGE_SIZE, safePage * LIBRARY_PAGE_SIZE) + const pageStart = visibleGames.length === 0 ? 0 : (safePage - 1) * LIBRARY_PAGE_SIZE + 1 + const pageEnd = Math.min(visibleGames.length, safePage * LIBRARY_PAGE_SIZE) useEffect(() => { setPage(1) @@ -585,34 +1545,51 @@ function LibraryPanel({ onSync() }} > - onFoxKeyword(event.target.value)} placeholder="输入野狐昵称 / UID" /> - - +
{keyword ? '野狐棋谱' : '棋谱库'} - {visibleGames.length} 盘 + {visibleGames.length} 盘 · {pageStart}-{pageEnd}
{pageGames.map((game) => ( ))} {pageGames.length === 0 ?
没有匹配的棋谱
: null}
-
- {safePage} / {pageCount} -
@@ -631,108 +1608,567 @@ function StatusPills({ items }: { items: StatusPill[] }): ReactElement { ) } +function DesktopTitleBar({ + statusItems, + onCommand +}: { + statusItems: StatusPill[] + onCommand: (command: DesktopCommand) => void +}): ReactElement { + return ( +
+
+ +
+ GoMentor +
+
+
+ +
+
+ +
+
+ ) +} + +function DesktopStatusBar({ + graphBusy, + graphProgress, + katagoReady, + llmReady, + busy +}: { + graphBusy: boolean + graphProgress: string + katagoReady: boolean + llmReady: boolean + busy: string +}): ReactElement { + return ( +
+ {graphBusy ? `Winrate ${graphProgress || 'analyzing'}` : 'Winrate ready'} + KataGo + Vision LLM + {busy ? `Task: ${busy}` : 'Ready'} +
+ ) +} + +function CommandPalette({ + open, + busy, + hasRecord, + hasGames, + onClose, + onRun +}: { + open: boolean + busy: string + hasRecord: boolean + hasGames: boolean + onClose: () => void + onRun: (command: DesktopCommand) => void +}): ReactElement | null { + const [query, setQuery] = useState('') + useEffect(() => { + if (open) { + setQuery('') + } + }, [open]) + const commands = useMemo(() => [ + { id: 'analyze-current' as const, title: '分析当前手', detail: '截图棋盘,调用 KataGo,再让老师讲解', shortcut: 'Ctrl/Cmd 1', disabled: !hasRecord || busy !== '' }, + { id: 'analyze-game' as const, title: '分析整盘围棋', detail: '扫描关键问题手和胜负转折点', shortcut: 'Ctrl/Cmd 2', disabled: !hasRecord || busy !== '' }, + { id: 'analyze-recent' as const, title: '分析近 10 局', detail: '聚合棋手稳定问题并更新画像', shortcut: 'Ctrl/Cmd 3', disabled: !hasGames || busy !== '' }, + { id: 'import-sgf' as const, title: '导入棋谱 SGF 文件', detail: '从本机文件系统添加棋谱', shortcut: 'Ctrl/Cmd O', disabled: busy !== '' }, + { id: 'open-settings' as const, title: '打开设置', detail: '配置模型、KataGo 资源和发布 readiness', shortcut: 'Ctrl/Cmd ,', disabled: false }, + { id: 'toggle-library' as const, title: '切换棋谱栏', detail: '收起或展开左侧棋手棋谱栏', shortcut: 'Ctrl/Cmd B', disabled: false }, + { id: 'open-ui-gallery' as const, title: '打开 UI Gallery', detail: '进入内部视觉 QA 样例页', shortcut: 'Ctrl/Cmd Shift G', disabled: false } + ], [busy, hasGames, hasRecord]) + const filtered = commands.filter((command) => { + const haystack = `${command.title} ${command.detail}`.toLowerCase() + return haystack.includes(query.trim().toLowerCase()) + }) + function handleKeyDown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + onClose() + } + if (event.key === 'Enter') { + const first = filtered.find((command) => !command.disabled) + if (first) { + onRun(first.id) + } + } + } + if (!open) { + return null + } + return ( +
+
event.stopPropagation()}> +
+ Command Palette + +
+ setQuery(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="输入任务或命令,例如:分析当前手、导入棋谱、打开设置" + /> +
+ {filtered.map((command) => ( + + ))} +
+
+
+ ) +} + +function DesktopPreferencesModal({ + open, + dashboard, + katagoAssets, + busy, + llmTestMessage, + katagoBenchmark, + katagoBenchmarkMessage, + katagoInstallMessage, + katagoInstallProgress, + onClose, + onSave, + onTest, + onBenchmark, + onInstallOfficialModel, + onRefreshKataGoAssets +}: { + open: boolean + dashboard: DashboardData + katagoAssets: KataGoAssetStatus | null + busy: string + llmTestMessage: string + katagoBenchmark: KataGoBenchmarkResult | null + katagoBenchmarkMessage: string + katagoInstallMessage: string + katagoInstallProgress: KataGoAssetInstallProgress | null + onClose: () => void + onSave: (form: HTMLFormElement) => void + onTest: (form: HTMLFormElement) => void + onBenchmark: () => void + onInstallOfficialModel: (presetId: KataGoModelPresetId) => void + onRefreshKataGoAssets: () => void +}): ReactElement | null { + if (!open) { + return null + } + return ( +
+
event.stopPropagation()}> +
+
+ 设置 + 桌面运行设置 +
+ +
+ +
+
+ ) +} + +function teacherResultKeyMoves(result?: TeacherRunResult): Array<{ moveNumber: number; title: string; summary: string; severity: string }> { + const structured = result?.structuredResult ?? result?.structured + return (structured?.keyMistakes ?? []).flatMap((move, index) => { + if (typeof move.moveNumber !== 'number') { + return [] + } + const title = `第 ${move.moveNumber} 手${move.played ? ` ${move.played}` : ''}` + const summary = move.explanation || move.evidence || '这手值得回到棋盘上单独看。' + return [{ + moveNumber: move.moveNumber, + title: title || `关键手 ${index + 1}`, + summary, + severity: move.errorType || move.severity || '重点' + }] + }).slice(0, 4) +} + +function renderInlineMarkdown(text: string): ReactNode[] { + return text.split(/(\*\*[^*]+\*\*)/g).map((part, index) => { + if (part.startsWith('**') && part.endsWith('**')) { + return {part.slice(2, -2)} + } + return part + }) +} + +function ChatMarkdown({ text }: { text: string }): ReactElement { + const lines = text.split(/\n+/).map((line) => line.trim()).filter(Boolean) + const nodes: ReactElement[] = [] + let list: ReactElement[] = [] + function flushList(): void { + if (list.length > 0) { + nodes.push(
    {list}
) + list = [] + } + } + for (const line of lines) { + const numbered = line.match(/^(\d+)[.、]\s*(.+)$/) + if (numbered) { + list.push(
  • {renderInlineMarkdown(numbered[2])}
  • ) + continue + } + flushList() + nodes.push(

    {renderInlineMarkdown(line)}

    ) + } + flushList() + return
    {nodes}
    +} + +function TeacherInlineResponse({ + message, + onJumpToMove, + onAnalyzeMove +}: { + message: ChatMessage + onJumpToMove: (moveNumber: number) => void + onAnalyzeMove: (moveNumber: number) => void +}): ReactElement { + const keyMoves = teacherResultKeyMoves(message.result) + const toolLogs = message.toolLogs ?? message.result?.toolLogs ?? [] + const isRunning = message.status === 'running' + const isTeacher = message.role === 'teacher' + return ( + <> + {isTeacher && toolLogs.length > 0 ? ( +
    + 工具调用 · {toolLogs.length} +
    + {toolLogs.map((log) => ( +

    + {log.label || log.name} + {log.detail || log.status} +

    + ))} +
    +
    + ) : null} +
    + {isTeacher && !message.content && isRunning ? ( +
    + +

    正在读取棋盘、KataGo 候选点和你的问题。

    +
    + ) : isTeacher ? ( + <> + + {isRunning ? : null} + + ) : message.content} +
    + {keyMoves.length > 0 ? ( +
    + {keyMoves.map((move) => ( + + ))} + +
    + ) : null} + + ) +} + function TeacherPanel({ messages, prompt, busy, - settingsOpen, dashboard, - llmTestMessage, + katagoAssets, error, onPrompt, onSubmit, onAnalyze, - onSettingsOpen, - onSaveSettings, - onTestLlm + onAnalyzeGame, + onAnalyzeRecent, + onJumpToMove, + onAnalyzeMove }: { messages: ChatMessage[] prompt: string busy: string - settingsOpen: boolean dashboard: DashboardData - llmTestMessage: string + katagoAssets: KataGoAssetStatus | null error: string onPrompt: (value: string) => void onSubmit: (event: FormEvent) => void onAnalyze: () => void - onSettingsOpen: () => void - onSaveSettings: (form: HTMLFormElement) => void - onTestLlm: (form: HTMLFormElement) => void + onAnalyzeGame: () => void + onAnalyzeRecent: () => void + onJumpToMove: (moveNumber: number) => void + onAnalyzeMove: (moveNumber: number) => void }): ReactElement { + const modelName = dashboard.settings.llmModel || '未选择模型' + const katagoLabel = katagoAssets?.ready || dashboard.systemProfile.katagoReady ? 'KataGo ready' : 'KataGo missing' + const llmLabel = dashboard.systemProfile.hasLlmApiKey ? 'Vision LLM ready' : 'LLM setup needed' + const hasRunningTask = busy === 'teacher' + const hasRunningMessage = messages.some((message) => message.role === 'teacher' && message.status === 'running') + const threadBottomRef = useRef(null) + useEffect(() => { + threadBottomRef.current?.scrollIntoView({ block: 'end' }) + }, [messages, busy, error]) return ( -
    -
    -
    - AI 围棋老师 - {busy === 'teacher' ? '执行中' : '待命'} +
    +
    +
    + Agent thread + GoMentor +
    + {modelName} + {katagoLabel} + {llmLabel} +
    -
    - - +
    + {hasRunningTask ? 'Running' : 'Ready'}
    -
    +
    - {settingsOpen ? ( - - ) : null} - -
    +
    {messages.map((message) => ( -
    -
    {message.role === 'teacher' ? '老师' : '学生'}
    -
    {message.content}
    - {message.result ? : null} +
    +
    +
    + {message.role === 'teacher' ? 'GoMentor' : 'User'} + {message.status ?? (message.result ? 'completed' : message.role === 'teacher' ? 'assistant' : 'prompt')} +
    + +
    ))} - {busy === 'teacher' ? ( -
    -
    老师
    -
    正在规划任务、调用工具和整理讲解...
    + {hasRunningTask && !hasRunningMessage ? ( +
    +
    +
    + GoMentor + running +
    +
    + +

    正在看棋盘、KataGo 候选点和你的问题,然后组织成一段能下次用上的讲解。

    +
    +
    ) : null} +
    {error ?
    {error}
    : null} -
    -