대결이 아니라 도움
바둑이나 오목과 달리 스도쿠에는 상대가 없습니다. AI가 혼자 다 풀어 버리면 사람이 할 일이 없습니다. 그래서 웹 스도쿠(stoku.mariago.kr)에서는 사람이 직접 풀고, 막힐 때만 AI에게 한 수를 묻게 했습니다.

한 수를 묻는 길
- 화면의 "AI 한수" 버튼이나 채팅으로 묻습니다. "한 수 둬", "힌트" 같은 말입니다.
- 채팅 서버는 Qwen2.5-1.5B-Instruct와 정규식으로 한 수 요청인지 가립니다.
- 한 수 요청이면 스도쿠 서버에 지금 판을 보내고, 칸 하나와 숫자 하나를 받아 사람의 판에 놓습니다.
지금 웹 서버는 VLM을 띄우지 않고 코드로만 확실한 수를 고릅니다. 빈칸이 하나 남은 박스·행·열을 먼저 보고, 없으면 숫자별로 자리가 하나뿐인 칸을 찾습니다. 4편에서 VLM과 코드가 나눠 하던 일을 코드가 모두 하는 셈입니다. 화면의 모델 목록에는 lora_move가 표시되지만 실제 수는 이 전략이 정합니다. 왜 그 칸인지는 아직 보여 주지 않습니다.
사진으로 가져오기
Qt 앱에는 종이나 화면의 스도쿠를 사진으로 가져오는 기능이 있습니다. 격자를 잘라 내고, 숫자가 있는 칸만 모아 3편의 판 읽기 8B LoRA에 읽힙니다.

웹에서 푼 기록
3주 남짓 동안 웹에서 40판이 열렸습니다. 26판을 끝까지 풀었고 평균 12분쯤 걸렸습니다. 9판은 그만뒀고 5판은 진행 중으로 남아 있습니다. 끝까지 푼 판은 보통 20판, 어려움 3판, 지옥 2판, 극악 1판입니다.
기록된 조작은 숫자 놓기 1,430번, 메모 135번, 지우기 62번, 되돌리기 23번입니다.

이 단계의 실제 코드
웹 서버가 확실한 수를 고르는 순서입니다.
vlm_digit_strategy.py
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.py
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. 전체 소스 코드와 사용한 모델’입니다.
COMMENTS · CHAPTER 05
사람을 돕는 AI 댓글 0
아직 댓글이 없습니다. 읽다가 궁금한 점이나 보탤 내용을 남겨 주세요.
댓글을 등록하려면 구글로 시작하기를 눌러 닉네임과 메일 주소를 등록해 주세요.
구글로 시작하기