게시판바둑 on-policy 학습: 대국 로그를 KataGo 라벨로 바꾸기예제 소스

예제 소스

바둑 on-policy 학습: 대국 로그를 KataGo 라벨로 바꾸기 · 파일 169개

mariago_ws_bridge.py

#!/usr/bin/env python3
"""MariaGO 웹 착수 브리지.

지금은 모든 착수를 로컬 휴리스틱으로 둔다. Gemma·R5 포트는 학습 대국이 쓴다.

  .venv/bin/python scripts/mariago_ws_bridge.py --vlm-port 9319 --ws-port 9320

프로토콜 (JSON):
  → {"type":"move","board":[361 ints],"ai_side":1|2,"game_epoch":int,"rejects":[[r,c],...]}
  ← {"type":"move","row":int,"col":int,"pass":false}
  ← {"type":"move","pass":true}
  ← {"type":"error","error":"..."}
  → {"type":"ping"} ← {"type":"pong"}
"""

from __future__ import annotations

import argparse
import asyncio
import importlib.util
import io
import json
import os
import random
import socket
import struct
import sys
from pathlib import Path
from typing import List, Optional, Tuple

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from vlm_server.dataset_baduk import BOARD_SIZE, render_board_png  # noqa: E402
from vlm_server.probe_move import MAGIC_GO19, MAGIC_RPLY, _recv_exact, _u32be  # noqa: E402

try:
    import websockets
except ImportError:
    print("websockets 패키지 필요: .venv/bin/pip install websockets", file=sys.stderr)
    raise SystemExit(1)

# websockets 12+ / 14+ API 모두 대응
try:
    from websockets.asyncio.server import serve as ws_serve
except ImportError:
    from websockets import serve as ws_serve  # type: ignore


def _empty_png() -> bytes:
    img = render_board_png(bytes(BOARD_SIZE * BOARD_SIZE), BOARD_SIZE, px=256)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return buf.getvalue()


def _send_go19_ext(
    sock: socket.socket,
    png: bytes,
    board: bytes,
    ai_side: int,
    *,
    game_epoch: int,
    rejects: List[Tuple[int, int]],
) -> None:
    body = bytearray()
    body.append(BOARD_SIZE)
    body.append(ai_side)
    body += _u32be(len(png))
    body += png
    body += board
    body += _u32be(int(game_epoch) & 0xFFFFFFFF)
    if rejects:
        n = min(64, len(rejects))
        body.append(n & 0xFF)
        for i in range(n):
            r, c = rejects[i]
            body.append(int(r) & 0xFF)
            body.append(int(c) & 0xFF)
    sock.sendall(MAGIC_GO19 + _u32be(len(body)) + body)


def _recv_reply(sock: socket.socket) -> Tuple[bool, Optional[int], Optional[int], Optional[str]]:
    head = _recv_exact(sock, 4)
    if head != MAGIC_RPLY:
        raise ValueError(f"bad reply header: {head!r}")
    flag = _recv_exact(sock, 1)[0]
    if flag == 1:
        row = _recv_exact(sock, 1)[0]
        col = _recv_exact(sock, 1)[0]
        if row == 0xFF and col == 0xFF:
            return True, None, None, None
        return True, int(row), int(col), None
    if flag == 0:
        (n,) = struct.unpack(">H", _recv_exact(sock, 2))
        msg = _recv_exact(sock, n).decode("utf-8", errors="replace")
        return False, None, None, msg
    raise ValueError(f"unknown flag: {flag}")


def request_move_sync(
    host: str,
    port: int,
    board: List[int],
    ai_side: int,
    game_epoch: int,
    rejects: List[Tuple[int, int]],
    timeout_s: float,
) -> dict:
    if len(board) != BOARD_SIZE * BOARD_SIZE:
        return {"type": "error", "error": f"board length must be {BOARD_SIZE*BOARD_SIZE}"}
    packed = bytes(int(x) & 0xFF for x in board)
    sock = socket.create_connection((host, port), timeout=15)
    sock.settimeout(timeout_s)
    try:
        _send_go19_ext(
            sock,
            _empty_png(),
            packed,
            ai_side,
            game_epoch=game_epoch,
            rejects=rejects,
        )
        ok, row, col, err = _recv_reply(sock)
        if not ok:
            return {"type": "error", "error": err or "vlm error"}
        if row is None or col is None:
            return {"type": "move", "pass": True}
        return {"type": "move", "pass": False, "row": row, "col": col}
    finally:
        try:
            sock.close()
        except Exception:
            pass


_monitor = None


def _monitor_mod():
    global _monitor
    if _monitor is None:
        path = ROOT / "scripts" / "monitor_mariago_web_play.py"
        spec = importlib.util.spec_from_file_location("monitor_mariago_web_play", path)
        if spec is None or spec.loader is None:
            raise RuntimeError("heuristic import failed")
        mod = importlib.util.module_from_spec(spec)
        sys.modules["monitor_mariago_web_play"] = mod
        spec.loader.exec_module(mod)
        _monitor = mod
    return _monitor


def local_ai_move(board: List[int], ai_side: int, rejects: List[Tuple[int, int]], ko: Optional[Tuple[int, int]]) -> dict:
    """이 요청만 로컬 휴리스틱으로 둔다. 서버 엔진은 바꾸지 않는다."""
    mon = _monitor_mod()
    banned = {(int(r), int(c)) for r, c in rejects}
    row, col, is_pass = mon.heuristic_move(list(board), int(ai_side), ko, random.Random(), banned)
    if is_pass or row is None or col is None:
        return {"type": "move", "pass": True}
    ok, reason, _nxt, _cap, _ko = mon.try_play(list(board), int(row), int(col), int(ai_side), ko)
    if not ok:
        return {"type": "error", "error": reason or "illegal"}
    return {"type": "move", "pass": False, "row": int(row), "col": int(col)}


async def handler(ws, *, vlm_host: str, vlm_port: int, timeout_s: float):
    async for raw in ws:
        try:
            msg = json.loads(raw)
        except json.JSONDecodeError:
            await ws.send(json.dumps({"type": "error", "error": "invalid json"}))
            continue
        mtype = msg.get("type")
        if mtype == "ping":
            await ws.send(json.dumps({"type": "pong"}))
            continue
        if mtype != "move":
            await ws.send(json.dumps({"type": "error", "error": f"unknown type: {mtype}"}))
            continue
        board = msg.get("board") or []
        ai_side = int(msg.get("ai_side") or 2)
        rejects_raw = msg.get("rejects") or []
        rejects: List[Tuple[int, int]] = []
        for item in rejects_raw:
            if isinstance(item, (list, tuple)) and len(item) >= 2:
                rejects.append((int(item[0]), int(item[1])))
        ko_raw = msg.get("ko")
        ko = None
        if isinstance(ko_raw, (list, tuple)) and len(ko_raw) >= 2:
            ko = (int(ko_raw[0]), int(ko_raw[1]))
        try:
            result = await asyncio.to_thread(local_ai_move, list(board), ai_side, rejects, ko)
        except Exception as e:
            result = {"type": "error", "error": str(e)}
        await ws.send(json.dumps(result, ensure_ascii=False))


async def main_async(args: argparse.Namespace) -> None:
    print(
        f"[mariago-bridge] ws://********:{args.ws_port} → heuristic only",
        flush=True,
    )

    async def _handler(ws):
        await handler(
            ws,
            vlm_host=args.vlm_host,
            vlm_port=args.vlm_port,
            timeout_s=args.timeout_s,
        )

    async with ws_serve(_handler, "********", args.ws_port):
        await asyncio.Future()


MOVE_PORT_FILE = ROOT / "config/mariago_move_port"


def default_move_port() -> int:
    """착수 서버 포트: MARIAGO_MOVE_PORT > config/mariago_move_port > 9319.

    앙상블 프록시(:9318)는 scripts/install_mariago_ensemble.sh on 이 이 파일을 쓴다.
    """
    env = os.environ.get("MARIAGO_MOVE_PORT", "").strip()
    if env:
        return int(env)
    try:
        return int(MOVE_PORT_FILE.read_text().strip())
    except (OSError, ValueError):
        return 9319


def main() -> int:
    p = argparse.ArgumentParser(description="MariaGO WebSocket → GO19 bridge")
    p.add_argument("--vlm-host", default="127.0.0.1")
    p.add_argument("--vlm-port", type=int, default=default_move_port())
    p.add_argument("--ws-port", type=int, default=9320)
    p.add_argument("--timeout-s", type=float, default=240.0)
    args = p.parse_args()
    asyncio.run(main_async(args))
    return 0


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