게시판SGF 기보로 학습하는 바둑 LLM 인공지능예제 소스

예제 소스

SGF 기보로 학습하는 바둑 LLM 인공지능 · 파일 169개

katago_analyze.py

#!/usr/bin/env python3
"""MariaGO board → KataGo analysis (score lead, winrate, ownership)."""

from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[1]
LETTERS = "ABCDEFGHJKLMNOPQRST"


def _brew_katago(prefix: Path) -> tuple[Path, Path, Path] | None:
    model = prefix / "share/katago/kata1-b18c384nbt-s9996604416-d4316597426.bin.gz"
    cfg = prefix / "share/katago/configs/analysis_example.cfg"
    exe = prefix / "bin/katago"
    if model.exists() and cfg.exists() and exe.exists():
        return exe, model, cfg
    return None


def katago_paths() -> tuple[Path, Path, Path]:
    # `brew --prefix` 는 API 데이터 갱신으로 수 초 걸릴 수 있어 흔한 경로를 먼저 본다.
    for prefix in (Path("/opt/homebrew/opt/katago"), Path("/usr/local/opt/katago")):
        found = _brew_katago(prefix)
        if found:
            return found
    brew = shutil.which("brew")
    if brew:
        try:
            prefix = Path(
                subprocess.check_output([brew, "--prefix", "katago"], text=True).strip()
            )
            found = _brew_katago(prefix)
            if found:
                return found
        except subprocess.CalledProcessError:
            pass
    local = ROOT / "tools/katago"
    return local / "katago", local / "model.bin.gz", local / "analysis.cfg"


def move_to_katago(m: dict[str, Any]) -> list[str]:
    color = "B" if int(m.get("color", 1)) == 1 else "W"
    if m.get("pass"):
        return [color, "pass"]
    r, c = int(m["r"]), int(m["c"])
    return [color, f"{LETTERS[c]}{19 - r}"]


def analyze(
    moves: list[dict[str, Any]],
    *,
    komi: float = 6.5,
    max_visits: int = 80,
    timeout_s: float = 45.0,
) -> dict[str, Any]:
    exe, model, cfg = katago_paths()
    if not exe.exists():
        raise FileNotFoundError(f"KataGo not found: {exe}")
    if not model.exists():
        raise FileNotFoundError(f"KataGo model not found: {model}")
    if not cfg.exists():
        raise FileNotFoundError(f"KataGo config not found: {cfg}")

    katago_moves = [move_to_katago(m) for m in moves]
    turn = len(katago_moves)
    req = {
        "id": "mariago",
        "initialStones": [],
        "moves": katago_moves,
        "rules": "korean",
        "komi": komi,
        "boardXSize": 19,
        "boardYSize": 19,
        "analyzeTurns": [turn],
        "maxVisits": max_visits,
        "includeOwnership": True,
        "includePolicy": False,
    }

    log_dir = ROOT / "logs/katago_analysis"
    log_dir.mkdir(parents=True, exist_ok=True)
    env = os.environ.copy()
    env["HOME"] = str(log_dir)

    proc = subprocess.Popen(
        [str(exe), "analysis", "-config", str(cfg), "-model", str(model)],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        bufsize=1,
        env=env,
    )
    assert proc.stdin and proc.stdout

    def feed() -> None:
        time.sleep(0.5)
        proc.stdin.write(json.dumps(req) + "\n")
        proc.stdin.flush()

    threading.Thread(target=feed, daemon=True).start()

    deadline = time.monotonic() + timeout_s
    last: dict[str, Any] | None = None
    while time.monotonic() < deadline:
        line = proc.stdout.readline()
        if not line:
            if proc.poll() is not None:
                break
            continue
        line = line.strip()
        if not line.startswith("{"):
            continue
        try:
            msg = json.loads(line)
        except json.JSONDecodeError:
            continue
        if msg.get("id") != "mariago":
            continue
        if msg.get("error"):
            proc.terminate()
            raise RuntimeError(msg["error"])
        last = msg
        if msg.get("isDuringSearch") is False:
            break

    proc.terminate()
    try:
        proc.wait(timeout=3)
    except subprocess.TimeoutExpired:
        proc.kill()

    if not last or "rootInfo" not in last:
        err = proc.stderr.read() if proc.stderr else ""
        raise RuntimeError(f"KataGo analysis failed. stderr tail: {err[-500:]}")

    root = last["rootInfo"]
    ownership = last.get("ownership") or []
    to_play = "B" if turn % 2 == 0 else "W"
    score_lead_black = float(root.get("scoreLead", 0))
    winrate_black = float(root.get("winrate", 0.5))

    black_area = sum(1 for x in ownership if x < -0.35)
    white_area = sum(1 for x in ownership if x > 0.35)
    contested = sum(1 for x in ownership if -0.35 <= x <= 0.35)

    if to_play == "W":
        score_lead_side = -score_lead_black
    else:
        score_lead_side = score_lead_black

    return {
        "turn": turn,
        "toPlay": to_play,
        "scoreLeadBlack": round(score_lead_black, 2),
        "scoreLeadSide": round(score_lead_side, 2),
        "winrateBlack": round(winrate_black, 4),
        "winrateWhite": round(1 - winrate_black, 4),
        "ownership": ownership,
        "summary": {
            "blackLikely": black_area,
            "whiteLikely": white_area,
            "contested": contested,
        },
        "rootInfo": root,
    }


def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--komi", type=float, default=6.5)
    p.add_argument("--max-visits", type=int, default=80)
    p.add_argument("--timeout-s", type=float, default=45.0)
    args = p.parse_args()
    raw = sys.stdin.read()
    body = json.loads(raw) if raw.strip() else {"moves": []}
    moves = body.get("moves") or []
    out = analyze(
        moves,
        komi=float(body.get("komi", args.komi)),
        max_visits=int(body.get("maxVisits", args.max_visits)),
        timeout_s=args.timeout_s,
    )
    print(json.dumps(out, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())