Skip to content
Merged

v0.2 #11

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.1
hooks:
# Run the linter.
- id: ruff-check
# Run the formatter.
- id: ruff-format
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.7.17
hooks:
- id: uv-lock
69 changes: 45 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# SHAPE: Shape Habits Analysis and Personalized Evaluation

SHAPE is an app to play Go with AI feedback, specifically designed to point out typical bad habits for your current skill level.
SHAPE is a Go app with AI feedback, designed to help you identify and correct common mistakes based on your skill level.

This is an experimental project, and is unlikely to ever become very polished.
![SHAPE Application Screenshot](assets/screenshot.png)

## Quick Start

Expand All @@ -14,39 +14,60 @@ uvx goshape

The first time you run this, `uv` will automatically download the package, create a virtual environment, and install all dependencies.

When the application starts for the first time, it will check for the required KataGo models in `~/.katrain/`. If they are not found, a dialog will appear to guide you through downloading them.
When the application starts for the first time, it will also check for the required KataGo models in `~/.katrain/`. If they are not found, a dialog will appear to guide you through downloading them.

## Manual
## Features

The most important settings are:
- **Personalized Mistake Analysis**: The AI feedback is tailored to your rank, flagging mistakes that are relevant to your level.
- **Interactive Board**: A clean, responsive Go board with heatmap visualizations.
- **Detailed AI Feedback**: An analysis tab shows the score graph and KataGo's top move considerations.
- **Configurable Opponent**: Play against an AI opponent with customizable rank, style, and behavior.
- **SGF Support**: Save and load games, or copy/paste SGF data from the clipboard.

- Current Rank: Your current Go skill level, which determines which mistakes are considered "typical" for your level.
- Target Rank: The skill level you want to aim for. Even if a move is a huge mistake, if it was a mistake still common at that level, it won't be considered relevant.
- Opponent: The type and level of the AI opponent.
- This supports modern, pre-alphago style, and historical professional style.
- Like the feedback, it is likely to be somewhat weaker than actual professionals or high-dan players.
## How to Use

The game will automatically halt when a typical mistake is made by you, allowing you to analyze, undo, or just continue.
The main window is divided into the board on the left and a control panel with three tabs on the right.

Keep in mind that the techniques used are more likely to be helpful up to low-dan levels, and may not be helpful at all at high levels.
### Board Controls

### Heatmap
Underneath the board, you will find the main game controls:
- **Pass**: Pass your turn.
- **Undo**: Go back one move. This button is only active when there are moves to undo.
- **Redo**: Go forward one move. This button is only active when you have undone moves.
- **AI Move**: Force the AI to make a move, even if it is your turn.

The policy heatmap shows the probability of the top moves being made for your current rank, target rank, and AI.
Note that a move being probable does not mean it is a good move.
You can select multiple heatmaps to get a blended view, where size/number is the average probability, and the color is the average rank (current, target, AI).
### The "Play" Tab

This is the main control center for your game.

## TODO list from Gemini
- **Game Control**:
- **Play as**: Choose to play as Black or White.
- **Opponent Controls**: Force the AI to move or enable **Auto-play** for the AI to play automatically when it's its turn.
- **Player Settings**:
- **Current Rank**: Your current Go skill level. This is used to determine which mistakes are typical for you.
- **Target Rank**: The skill level you want to aim for. Mistakes common at this level won't be flagged.
- **Opponent**: The type and level of the AI opponent. This supports modern, pre-AlphaGo style, and historical professional styles.
- **Heatmap**: Visualize the AI's preferred moves for different player models (Current Rank, Target Rank, AI, and Opponent). You can select multiple heatmaps to get a blended view.
- **Info Panel**: A collapsible panel (shortcut: `Ctrl+0`) that shows detailed statistics about the last move.

Based on a code review, here are some suggested areas for improvement:
### The "AI Analysis" Tab

### High Impact
- **User-Friendly Errors (`main.py`):** Show GUI dialogs for errors instead of crashing the application.
This tab provides feedback from the AI.
- **Score Graph**: A graph showing the score progression over the course of the game. The Y-axis is centered at 0, with a dashed line indicating an even game.
- **Top Moves**: A table showing KataGo's top 5 recommended moves for the current position, including win rate, score lead, and visits.
- **Deeper AI Analysis**: A button to request a much deeper analysis (more visits) for the current position.

### Medium Impact
- **Refactor `GameNode` (`game_logic.py`):** Extract board state and rule logic into a separate `Board` class to simplify `GameNode` and improve modularity.
### The "Settings" Tab

### Low Impact
- **Code Clarity (`game_logic.py`):** Improve code readability.
This tab allows you to fine-tune the AI's behavior.

- **Policy Sampling**: These settings affect the AI's move selection and the heatmap visualization. Tooltips are provided in the app for detailed explanations.
- **Top K**: Considers only the top K moves.
- **Top P**: Considers moves from the smallest set whose cumulative probability exceeds P.
- **Min P**: Considers only moves with a probability of at least P times the probability of the best move.
- **Analysis Settings**:
- **Visits**: The number of playouts the AI will perform for its analysis. Higher values lead to stronger play but require more processing time.
- **Mistake Feedback**: These settings determine when the game will automatically halt. The game halts if the mistake size is above the configured threshold **AND** either of the probability conditions are met.

Keep in mind that the techniques used are more likely to be helpful up to low-dan levels, and may not be as effective at high-dan or professional levels.

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
[project]
name = "goshape"
version = "0.1.0"
version = "0.2.0"
description = "Shape Habits Analysis and Personalized Evaluation"
authors = [{name = "Sander Land"}]
readme = "README.md"
requires-python = ">=3.10,<3.14"
dependencies = [
"PySide6>=6.5.0",
"pyqtgraph>=0.13.7",
"pysgf>=0.9.0",
"numpy>=2.1.2",
"httpx>=0.25.0",
"matplotlib>=3.5.0",
]

[dependency-groups]
Expand All @@ -22,6 +22,7 @@ dev = [

[project.scripts]
goshape = "shape.main:main"
shape = "shape.main:main"

[tool.hatch.build.targets.wheel]
packages = ["shape"]
Expand Down
92 changes: 81 additions & 11 deletions shape/game_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ def sample(
if prob > 0
]
if self.pass_prob > 0 and not exclude_pass:
moves.append(("pass", self.pass_prob, None))
moves.append((Move(coords=None), self.pass_prob, self.pass_prob))
moves.sort(key=lambda x: x[1], reverse=True)
if not moves:
return [], "no_moves"
highest_prob = moves[0][1]
top_moves = []
total_prob = 0
Expand Down Expand Up @@ -95,23 +97,46 @@ class GameNode(SGFNode):
def __init__(self, parent: "GameNode | None" = None, properties=None, move=None):
super().__init__(parent, properties, move)
if parent:
assert move is not None
self.board_state = self._board_state_after_move(parent.board_state, move)
if move:
self.board_state = self._board_state_after_move(parent.board_state, move)
else:
self.board_state = copy.deepcopy(parent.board_state)
else:
bx, by = self.board_size
self.board_state: list[list[str | None]] = [[None for _ in range(bx)] for _ in range(by)]
self.analyses = {}
self.ai_move_requested = False # flag to indicate if ai move was manually requested
self.autoplay_halted_reason: str | None = None # flag to indicate if autoplay was automatically halted

def _recalculate_board_states(self):
if self.parent:
assert isinstance(self.parent, GameNode)
if self.move:
self.board_state = self._board_state_after_move(self.parent.board_state, self.move)
else:
self.board_state = copy.deepcopy(self.parent.board_state)
else:
bx, by = self.board_size
self.board_state: list[list[str | None]] = [[None for _ in range(bx)] for _ in range(by)]

for child in self.children:
assert isinstance(child, GameNode)
child._recalculate_board_states()

def get_last_node(self):
node = self
while node.children:
node = node.children[0]
return node

@property
def square_board_size(self) -> int:
bx, by = self.board_size
assert bx == by, "Non-square board size not supported"
return bx

def game_ended(self) -> bool:
return self.is_pass and self.parent.is_pass
return self.parent is not None and self.is_pass and self.parent.is_pass

def delete_child(self, child: "GameNode"):
self.children = [c for c in self.children if c is not child]
Expand Down Expand Up @@ -202,14 +227,18 @@ def analysis_requested(self, key: str | None):

def get_analysis(self, key: str | None, parent: bool = False) -> Analysis | None:
if parent:
return self.parent.get_analysis(key) if self.parent else None
if not self.parent:
return None
assert isinstance(self.parent, GameNode)
return self.parent.get_analysis(key)
analysis = self.analyses.get(key)
return None if analysis is Analysis.REQUESTED else analysis

def mistake_size(self) -> float | None:
current_analysis = self.get_analysis(None)
parent_analysis = None
if self.parent:
assert isinstance(self.parent, GameNode)
parent_analysis = self.parent.get_analysis(None)

if not current_analysis or not parent_analysis:
Expand All @@ -233,7 +262,7 @@ def __init__(self):
self.new_game()

def new_game(self, board_size=19, **rules):
self.current_node = GameNode(properties={"RU": "JP", "KM": 6.5, "SZ": board_size, **rules})
self.current_node: GameNode = GameNode(properties={"RU": "JP", "KM": 6.5, "SZ": board_size, **rules})

def __getattr__(self, attr):
if not hasattr(self.current_node, attr):
Expand All @@ -243,23 +272,64 @@ def __getattr__(self, attr):
def make_move(self, move: Move) -> bool:
if not self.current_node._is_valid_move(move):
return False
self.current_node = self.current_node.play(move)
new_node = self.current_node.play(move)
assert isinstance(new_node, GameNode)
self.current_node = new_node
return True

def undo_move(self, n: int = 1):
while (n := n - 1) >= 0 and self.current_node.parent:
assert isinstance(self.current_node.parent, GameNode)
self.current_node = self.current_node.parent

def redo_move(self, n: int = 1):
while (n := n - 1) >= 0 and self.current_node.children:
assert isinstance(self.current_node.children[0], GameNode)
self.current_node = self.current_node.children[0]

def get_score_history(self) -> list[tuple[int, float]]:
nodes = self.current_node.nodes_from_root
return [(node.depth, ai_analysis.ai_score()) for node in nodes if (ai_analysis := node.get_analysis(None))]
"""Returns a list of (move_number, score) tuples."""
history = []
node = self.current_node
while node:
analysis = node.get_analysis(None)
if analysis and analysis.ai_score() is not None:
score = analysis.ai_score()
if score is not None:
move_number = getattr(node, "move_number", 0)
history.append((move_number, score))
if not node.parent:
break
assert isinstance(node.parent, GameNode)
node = node.parent
return list(reversed(history))

def get_full_history(self) -> list[GameNode]:
history = []
node = self.current_node
while node:
history.append(node)
if not node.parent:
break
assert isinstance(node.parent, GameNode)
node = node.parent
return list(reversed(history))

def export_sgf(self, player_names):
return self.current_node.root.sgf()
root_node = self.get_full_history()[0]
root_node.properties["PB"] = player_names.get("B", "Black")
root_node.properties["PW"] = player_names.get("W", "White")
return root_node.sgf()

def import_sgf(self, sgf_data: str):
self.current_node = ShapeSGF.parse(sgf_data)
try:
parsed_sgf_root = ShapeSGF.parse(sgf_data)
assert isinstance(parsed_sgf_root, GameNode)
parsed_sgf_root._recalculate_board_states()
last_node = parsed_sgf_root.get_last_node()
assert isinstance(last_node, GameNode)
self.current_node = last_node
return True
except Exception as e:
logger.error(f"Failed to import SGF: {e}")
return False
34 changes: 24 additions & 10 deletions shape/main.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import argparse
import signal
import sys
import traceback

from PySide6.QtWidgets import QApplication
from PySide6.QtWidgets import QApplication, QMessageBox

from shape.katago.engine import KataGoEngine
from shape.ui.main_window import MainWindow
Expand All @@ -13,28 +14,41 @@
signal.signal(signal.SIGINT, signal.SIG_DFL) # hard exit on SIGINT


def show_error_dialog(exc_type, exc_value, exc_tb):
"""Show a dialog for unhandled exceptions."""
tb_str = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
error_message = f"An unexpected error occurred:\n\n{exc_value}\n\nTraceback:\n{tb_str}"
logger.error(error_message)

# Ensure QApplication instance exists
if QApplication.instance() is None:
_ = QApplication(sys.argv)

msg_box = QMessageBox()
msg_box.setIcon(QMessageBox.Icon.Critical)
msg_box.setText("An unexpected error occurred.")
msg_box.setInformativeText(str(exc_value))
msg_box.setDetailedText(tb_str)
msg_box.setWindowTitle("Error")
msg_box.exec()
sys.exit(1)


class SHAPEApp:
def __init__(self):
self.app = QApplication(sys.argv)
self.main_window = MainWindow()

try:
self.katago = KataGoEngine()
except Exception as e:
self.show_error(f"Failed to initialize KataGo engine: {e}")
sys.exit(1)

self.katago = KataGoEngine()
self.main_window.set_engine(self.katago)

def run(self):
self.main_window.show()
return self.app.exec()

def show_error(self, message):
logger.error(message)


def main():
sys.excepthook = show_error_dialog
parser = argparse.ArgumentParser(description="SHAPE: Shape Habits Analysis and Personalized Evaluation")
parser.parse_args()

Expand Down
Loading