変えた順序

  1. AIによる盤の設計: モデルは文字なしで黒マスの座標だけを出します。180°対称、黒マス40〜60%、縞模様・長いはしご・離れた島は避けるよう求めます。
  2. 盤の検証・補正: モデルが自分で付けた点数は信じず、プログラムが検査します。対称かどうか、白マスがすべてつながっているか、すべての白マスが2文字以上の単語に属しているか、最も長いマスが制限を超えていないか、4文字以上のマスが十分にあるかを見ます。ずれていれば、長い列を切り、短い列を埋め、塞がった所を開けて補正します。
  3. 単語の埋め込み: 合う候補が最も少ないマスから埋め、行き詰まったら戻って別の単語を入れます。AIの盤がうまくいかなければ、あらかじめ描いておいた盤(サイズごとに8個)を使い、それでもだめならランダムな盤を作ります。
  4. AIによる問題作成: 盤に確定した単語のリストだけをモデルに渡します。リストにない単語を足したり綴りを変えたりしたら捨て、問題に答えがそのまま入るのもいけません。

モデルは Mac の Ollama で動かしている gemma4 です。OpenAI 互換のアドレスで呼ぶので、設定を変えるだけで別のモデルも使えます。

意味の説明の代わりに常識と時事

単語の意味だけを問う問題は簡単すぎました。そこで、こんなクイズ形式の問題を入れました。

  • 遠くから見れば喜劇、近くで見れば悲劇だと言った人(채플린、チャップリン)
  • 海の石灰岩地帯にできる巨大な青い穴(블루홀、ブルーホール)
  • サンフランシスコの名所であるこの橋の名前(금문、ゴールデンゲート)
  • 夢を分析して無意識を語った心理学者(프로이트、フロイト)

時事問題も入れました。氷河が崩れて大洪水が起きた国、アメリカ・メキシコ・カナダが共同で開いたワールドカップ、ミラノ・コルティナ冬季オリンピックといった問題です。盤を作るたびに Google ニュースの見出しを読み、ウィキペディアのランダムな記事から単語をさらに取ってきます。

単語バンクには今1,102個あります。

  • 長さ: 2文字568個、3文字291個、4文字168個、5文字74個
  • 分野: 日常810個、科学86個、歴史60個、技術49個、古語34個、文化30個、地理21個、時事12個
  • クイズ形式の問題: 147個
  • 字幕の頻度順位で、よく使う言葉、普通の言葉、まれな言葉の等級を付けます。

一つの端末で最近出た単語180個は覚えておき、次の盤から除きます。同じ単語が続けて出ていた問題のためです。

難易度

  • やさしい: 8×8、開始時に白マスの18%を公開、答えは5文字まで
  • ふつう: 10×10、8%を公開、6文字まで
  • むずかしい: 12×12、公開なし、8文字まで

難易度ごとに、ヨコ・タテの問題数と真ん中の交差の数の最小値も変えています。むずかしいは、ヨコ8個、タテ6個、真ん中の交差6個以上です。

途中で抜けても続きから

最初の待合室には「続きから」ボタンがありました。むずかしいの盤を解いている途中でやさしいを選ぶと「続きから」が表示され、新しい盤なのか続きの盤なのか紛らわしくなっていました。そこで待合室の考え方を変えました。

  • 待合室では新しいゲームだけを作ります。
  • 解きかけの盤があれば、待合室にとどまらずにすぐゲームルームに入ります。
  • ゲームルームで待合室や新しいゲームを押すと、今の問題をあきらめるかどうかを尋ねます。

次の問題は記録が消えることでした。最後の入力をしてウィンドウを閉じ、待合室に入ると、解いていた盤がDBから消えていました。待合室に入るときに進行中の盤を整理しながら消していたのです。今は1マスでも入力した盤は「途中保存」として残し、何も入力していない盤だけを消します。ゲームルームには保存ボタンもあり、記録の一覧から開き直せば続きから解けます。

スマートフォンの画面

スマートフォンでは、仮想キーパッドが画面の半分を覆っていました。問題も A1 のような座標で表示されていて探しにくくなっていました。そこで変えました。

  • 盤の番号を押すとヨコの問題から表示され、ヨコ/タテのタブで切り替えます。
  • 1文字ずつではなく答えをまとめて書いてエンターを押すと、ヨコの問題はヨコに、タテの問題はタテに埋まります。
  • マスを押してもキーパッドがすぐには出ないので、先に盤を見られます。

この段階の実際のコード

新しい盤を作る API です。盤 → 単語 → 問題の順序がそのまま見えます。

app/api/puzzle/route.tstypescript · 36行
// 1. 모델이 글자 없는 판 구조를 먼저 제안한다.
const patternCandidates = await generateAiPatternCandidates(difficulty);
// 2. 판의 슬롯 길이가 정해진 뒤 후보 단어를 모아 실제로 채운다.
const dynamicBank = await loadDynamicBank(difficulty, avoidWords, { useLlm: false });
let puzzle: Puzzle | null = null;
let patternSource: "ai" | "curated" = "curated";
// AI의 판 초안을 목표 모양으로 삼고, 연결형 배치기가 실제 단어를 고른다.
for (const rows of patternCandidates) {
  puzzle = generatePuzzleFromRows(difficulty, rows, {
    avoidWords,
    extraBank: dynamicBank.entries,
  });
  if (puzzle) {
    patternSource = "ai";
    break;
  }
}
if (!puzzle) {
  puzzle = generatePuzzle(difficulty, {
    avoidWords,
    extraBank: dynamicBank.entries,
  });
}
if (!puzzleMeets(puzzle, difficulty)) {
  puzzle = generatePuzzle(difficulty, { extraBank: dynamicBank.entries });
}
if (!puzzleMeets(puzzle, difficulty)) {
  puzzle = generatePuzzle(difficulty);
}

// 3. 최종 선택된 정답만 모델에 전달해 문제를 쓴다.
const selected = puzzle.across
  .concat(puzzle.down)
  .map(({ word, clue }) => ({ word, clue }));
const generatedClues = await generateCluesForWords(difficulty, selected);
puzzle = applyGeneratedClues(puzzle, generatedClues);

待合室に出るときに盤を整理する部分です。空の盤は消し、入力した盤は途中保存として残します。

lib/db.tstypescript · 12行
for (const row of rows) {
  if (isEmptyBoard(row)) {
    d.prepare(`DELETE FROM moves WHERE code = ?`).run(row.code);
    d.prepare(`DELETE FROM games WHERE code = ?`).run(row.code);
    continue;
  }
  d.prepare(
    `UPDATE games
     SET status = 'abandoned', end_text = COALESCE(NULLIF(end_text, ''), '중간 저장'), updated_at = ?
     WHERE code = ?`,
  ).run(now, row.code);
}

次の記事は「3. 統計で見直した出題の過程と問題点」です。