Help, not a match

Unlike Go or Gomoku, sudoku has no opponent. If the AI solves everything by itself, there is nothing left for the person to do. So in the web sudoku (stoku.mariago.kr), people solve the puzzle themselves and ask the AI for a move only when they get stuck.

The flow where a person asks and the AI returns one certain cell
The flow where a person asks and the AI returns one certain cell

Ways to ask for a move

  • Ask with the "AI move" button on the screen or in the chat, with phrases like "make a move" or "hint".
  • The chat server uses Qwen2.5-1.5B-Instruct and regular expressions to tell whether it is a move request.
  • If it is a move request, it sends the current board to the sudoku server, receives one cell and one digit, and places it on the person's board.

The web server currently does not load the VLM and picks certain moves with code alone. It first looks for a box, row or column with one empty cell left, and if there is none, finds a cell that is the only place for some digit. In effect, the code does all the work that the VLM and the code shared in post 4. The model list on the screen shows lora_move, but the actual move is decided by this strategy. It does not yet show why that cell was chosen.

Importing from a photo

The Qt app can import a sudoku from paper or a screen as a photo. It crops the grid, gathers only the cells with digits and has the board-reading 8B LoRA from post 3 read them.

Importing a photo. Crop the grid and read only the cells with digits
Importing a photo. Crop the grid and read only the cells with digits

Records of solving on the web

Over a little more than three weeks, 40 games were opened on the web. 26 were solved to the end, taking about 12 minutes on average. 9 were abandoned and 5 remain in progress. The completed games were 20 medium, 3 hard, 2 hell and 1 extreme.

The recorded actions were 1,430 digit placements, 135 notes, 62 erasures and 23 undos.

40 games solved by people on the web and their action records
40 games solved by people on the web and their action records

The actual code for this step

This is the order in which the web server picks a certain move.

vlm_digit_strategy.pypython · 23 lines
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)

This is the part where the chat server receives a message and, if it is a move request, passes it to the sudoku server.

chat_bridge_server.pypython · 60 lines
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,
        }

The next post is "6. The full source code and the models used".