対決ではなく手助け

囲碁や五目並べと違って、数独には相手がいません。AIが一人で全部解いてしまうと、人がすることがなくなります。そこでWeb数独(stoku.mariago.kr)では、人が自分で解き、行き詰まったときだけAIに一手を尋ねるようにしました。

人が尋ね、AIが確実な1マスを返す流れ
人が尋ね、AIが確実な1マスを返す流れ

一手を尋ねる方法

  • 画面の「AI の一手」ボタンかチャットで尋ねます。「一手打って」「ヒント」といった言葉です。
  • チャットサーバーは Qwen2.5-1.5B-Instruct と正規表現で、一手の要求かどうかを見分けます。
  • 一手の要求なら数独サーバーに今の盤を送り、マス一つと数字一つを受け取って人の盤に置きます。

今のWebサーバーは VLM を起動せず、コードだけで確実な手を選んでいます。空きマスが一つ残ったボックス・行・列を先に見て、なければ数字ごとに場所が一つしかないマスを探します。4回目の記事で VLM とコードが分担していた仕事を、コードがすべて行っているわけです。画面のモデル一覧には lora_move と表示されますが、実際の手はこの戦略が決めています。なぜそのマスなのかは、まだ見せていません。

写真から取り込む

Qt アプリには、紙や画面の数独を写真で取り込む機能があります。格子を切り出し、数字があるマスだけを集めて、3回目の記事の盤読み取り 8B LoRA に読ませます。

写真の取り込み。格子を切り出し、数字のマスだけを集めて読む
写真の取り込み。格子を切り出し、数字のマスだけを集めて読む

Webで解いた記録

3週間あまりの間に、Webで40局が開かれました。26局を最後まで解き、平均12分ほどかかりました。9局は途中でやめ、5局は進行中のまま残っています。最後まで解いた局は、ふつう20局、むずかしい3局、地獄2局、極悪1局です。

記録された操作は、数字の配置1,430回、メモ135回、消去62回、元に戻す23回です。

Webで人が解いた40局と操作の記録
Webで人が解いた40局と操作の記録

この段階の実際のコード

Webサーバーが確実な手を選ぶ順序です。

vlm_digit_strategy.pypython · 23行
def pick_code_certain_move(
    board: list[list[int]],
) -> tuple[int, int, int, str] | None:
    """우선순위: last-empty → digit 유일 → human solver → 탐색 강제수."""
    lasts = last_empty_unit_moves(board)
    if lasts:
        return lasts[0]

    for digit in range(1, 10):
        illegal = set(illegal_cells_for_digit(board, digit))
        moves = absolute_placements(board, illegal, digit)
        if moves:
            r, c, v, reason = moves[0]
            return r, c, v, f"code_digit{digit}_{reason}"

    from sudoku_human_solver import find_human_move

    hm = find_human_move(board)
    if hm is not None:
        return hm.row, hm.col, hm.value, f"human_{hm.kind.value}"

    # 극한: 국소 후보는 여러 개인데, 하나만 해가 남는 칸 (예: 2행 끝 7)
    return pick_search_forced_move(board)

チャットサーバーがメッセージを受け取り、一手の要求なら数独サーバーへ渡す部分です。

chat_bridge_server.pypython · 60行
MOVE_RE = re.compile(
    r"(한\s*수|다음\s*수|힌트|추천|어디|둬|놓|채워|풀어|solve|hint|move|next)",
    re.I,
)

# ...

def handle_chat(body: dict[str, Any]) -> dict[str, Any]:
    message = str(body.get("message") or "").strip()
    if not message:
        return {"error": "empty message"}

    model_id = str(body.get("modelId") or "lora_move")
    meta = TRAINED_MODELS.get(model_id, TRAINED_MODELS["lora_move"])
    board = body.get("board")
    puzzle = body.get("puzzle")
    difficulty = body.get("difficulty") or "medium"

    if not isinstance(board, list) or len(board) != 9:
        return {"error": "board required"}

    blanks = _empty_count(board)
    intent = classify_intent(message)

    llm_text = chat_llm_reply(
        message,
        {
            "model_label": meta["label"],
            "blanks": blanks,
            "difficulty": difficulty,
        },
    )

    if llm_text:
        if "CMD:MOVE" in llm_text.upper() or intent == "move":
            intent = "move"
        reply_base = re.sub(r"CMD:\w+", "", llm_text, flags=re.I).strip()
    else:
        reply_base = ""

    if intent == "move" or (intent == "unknown" and MOVE_RE.search(message)):
        result = call_trained_vlm(board, puzzle if isinstance(puzzle, list) else None, model_id)
        if not result.get("ok"):
            return {
                "reply": (
                    f"{meta['label']} 호출 실패: {result.get('error')}\n"
                    f"(채팅 AI → 학습 모델 명령 전달)"
                ),
                "action": None,
                "vlm": result,
            }
        r, c, v = result["row"], result["col"], result["value"]
        spoken = reply_base or (
            f"{meta['label']}에 「한 수」 명령을 전달했습니다."
        )
        return {
            "reply": f"{spoken}\n→ ({r},{c}) = {v}",
            "action": {"type": "place", "row": r, "col": c, "value": v},
            "vlm": result,
        }

次の記事は「6. 全ソースコードと使用したモデル」です。