Markdown AIで「存在する言葉を使ってはいけない」しりとりゲームを作ってみた

# はじめに
本記事はAdvent Calendar 2024、「Markdown AIのサーバーAI機能を使ってWebサイトを作ってみよう by MarkdownAI」シリーズ1、9日目の記事です。

https://qiita.com/advent-calendar/2024/markdownai

複数のAIを使って、「人」対「AI」対「AI」の三角関係で何かやってみようと思い立ち、可能な限り~~馬鹿馬鹿しい、ふざけた、~~ 実験的なものにしようと、掲題のゲームを作ってみました。

# 作ってみた
下記リンク先が掲題テーマを実装したサイトです。

https://mdown.ai/content/7c20f27e-57db-408c-af20-c74242fd3a03

# ざっくりロジック
1. ユーザの入力をGemini（しりとりジャッジマン）が「存在する言葉か」を判断します
1. 前項で「存在しない言葉」と判断されたらgpt-4o-mini（しりとりファイター）がしりとりの回答をします
1. しりとりファイターの回答をしりとりジャッジマンが「存在する言葉か」を判断します

# AIごとのプロンプト
本記事執筆時点では以下のようにプロンプトを設定しています。

## Gemini（しりとりジャッジマン）

```plaintext:prompt
あなたは入力された言葉を検査する役割です。
入力された言葉が存在するかしないかを、辞書やWikipediaで調べてください。存在しているか否かは完全一致していることを条件としてください。
回答は全て「存在する言葉です」か「存在しない言葉です」のいずれかで返してください。あなたが「存在する言葉です」と返答した場合に限り、その言葉の意味と情報源を追伸してください。
もし10秒以内に回答できなければ「存在しない言葉です」と返答してください。
```

しりとりジャッジマンは特に調整などせずに割と精度良く判定してくれている気がしています。

## gpt-4o-mini（しりとりファイター）

```plaintext:prompt
#役割
あなたの役割はしりとりの回答をすることです。

#条件
頭文字が入力された文字であること。
5文字〜10文字のひらがなであること。
ランダムな文字列であること。
文字列末尾に"ん"を使用しないこと。
文字列には"ゃ, ゅ, ょ, っ"を使用しないこと。
辞書に存在する言葉を含めないこと。
一度回答した文字列と類似する文字列は生成しないこと。
生成した候補のうち、一つだけを回答すること。
```

一番苦労して調整しているところです。
何度も書き換えては直し、書き換えては直し...の繰り返し。そもそもgpt-4o-miniは日本語でのしりとりをきちんと理解できていない様子。

# 一旦作り終えてみて
きちんと作り込むのであれば、以下の要素も入れたかったなぁと思っています。
1. しりとりの入力・回答が日本語として発音可能か否かの判定。これもAIで検査できる気がしましたが、しりとりファイターのプロンプト調整で思いの外時間がかかってしまったので断念し、「小文字は入力しちゃダメ」ルールを作ってしまいました
1. １ゲーム内で同じ言葉が2回以上出現した場合に負けとする条件。実装自体は単純なんですが、しりとりファイターがあまりにその条件で負けまくったので、その敗北条件は落としました
1. 画面デザイン。もっと綺麗に整形したい思いはありつつ、がっつりHTMLを書くことは趣旨と異なるためやめました。Markdownだけだとモダンなデザインにしづらいなぁと思いつつも、Markdownを謳っているサイトにBootstrapを適用するのも失礼じゃないかしら、という後ろめたさもあります

# 最後に
Markdown AIで記述した実装の全量は以下の通りです。AIのIDだけ伏せ字にしています。
稚拙な実装ですが、何かの参考になってくれればと思い掲載させていただきます。
```html:Markdown AI
<style>
input, button {
  padding: 10px;
  font-size: 16px;
  margin: 10px 0;
  border-radius: 4px;
  border: 1px solid #ccc;
}
button{
  background-color: #333;
  color: white;
  cursor: pointer;
}
button:disabled{
  background-color: #999;
}
input:user-invalid:not(:focus) {
  background-color: #fff0f5;
  border: 1px solid red;
}
</style>
# 存在する言葉を使ってはいけないしりとり
## ルール
* 基本的な遊び方は通常のしりとりと同じです。但し「存在する言葉」を使ってはいけません。
* 例えばあなたが「りんご」と回答した場合、これは存在する言葉なのであなたの負けになります。
* もちろん最後が「ん」の言葉を回答しても負けになります。
* その他に、以下のルールを設けています。
 * 回答する言葉は全てひらがなであること
 * 2文字以上、10文字以下の回答であること
 * 平仮名であっても、以下の文字は使用してはいけません。（旧仮名と小文字）
「ゐ」「ゑ」「を」「ゔ」「ぁ」「ぃ」「ぅ」「ぇ」「ぉ」「ヵ」「ヶ」「っ」「ゃ」「ゅ」「ょ」「ゎ」
 * 「ん」が2文字以上連続する言葉は禁止です

---

## さぁ遊ぼう
### あなたの回答

<input type="text" id="request-word" minlength="2" maxlength="10" autocomplete="off" placeholder="そんざいしないことば" reqired><button type="button" id="button-judge">送信</button><input type="hidden" id="last-word" value="">

---

### しりとりジャッジマンの判定
<div id="answer-judge">待機中</div>

---

### しりとりファイターの回答
<div id="answer-fighter">待機中</div>

<script>
(() => {
  const serverAi = new ServerAI();
  const judgeButton = document.getElementById('button-judge');
  const judgeElem = document.getElementById("answer-judge");
  const fighterElem = document.getElementById("answer-fighter");
  const lastWordElem = document.getElementById("last-word");

  function logicJudge(actor, word) {
    if (word == null || word.length < 2) {
      gameEnd(actor, "2文字未満の言葉は反則です。");
      return false;
    }
    if (word.length > 10) {
      gameEnd(actor, "11文字以上の言葉は反則です。");
      return false;
    }
    if (!word.match(/^[ぁ-ん]+$/u)) {
      gameEnd(actor, "ひらがな以外の文字入力は反則です。");
      return false;
    }
    if (word.match(/[ぁぃぅぇぉゐゑをっヵヶをゔゃゅょゎ]/u)) {
      gameEnd(actor, "禁則文字を使用しました。");
      return false;
    }
    if (word.match(/[ん]{2,}/u)) {
      gameEnd(actor, "「ん」が2回以上続く言葉を使用しました。");
      return false;
    }
    if (lastWordElem.value != "" && lastWordElem.value != word.substr(0, 1)) {
      gameEnd(actor, "先頭が「" + lastWordElem.value + "」ではありません。");
      return false;
    }
    if (word.match(/[ん]$/u)) {
      gameEnd(actor, "末尾が「ん」の言葉を使用しました。");
      return false;
    }
    return true;
  }

  function gameEnd(actor, message) {
    judgeElem.innerText = actor + "の負け。" + message;
    lastWordElem.value = "";
    judgeButton.disabled = false;
    alert(actor + "の負けです。");
  }

  judgeButton.addEventListener('click', async event => {
    judgeButton.disabled = true;
    judgeElem.innerText = "判定中...";

    // AIに問い合わせる前に基本的な制限をチェック
    let requestWord = document.getElementById("request-word").value;
    if (!logicJudge("あなた", requestWord)) {
      return;
    }
    let lastChar = requestWord.slice(-1);
    lastWordElem.value = lastChar;

    // ジャッジマンの判定
    const answer = await serverAi.getAnswerText('yyyyyyyyyyyyyyyyy', '', requestWord);
    judgeElem.innerText = answer;
    if (!answer.includes("存在しない言葉です")) {
      lastWordElem.value = "";
      judgeButton.disabled = false;
      alert("あなたの負けです。");
      return;
    }

    // しりとりファイターのターン
    fighterElem.innerText = "考え中...";
    const fighterAnswer = await serverAi.getAnswerText('xxxxxxxxxxxxxxxxx', '', lastChar);
    fighterElem.innerText = fighterAnswer;

    // AIに問い合わせる前に基本的な制限をチェック
    judgeElem.innerText = "判定中...";
    if (!logicJudge("しりとりファイター", fighterAnswer)) {
      return;
    }
    lastWordElem.value = fighterAnswer.slice(-1);

    const answer2 = await serverAi.getAnswerText('yyyyyyyyyyyyyyyyy', '', fighterAnswer);

    judgeElem.innerText = answer2;
    if (!answer2.includes("存在しない言葉です")) {
      lastWordElem.value = "";
      judgeButton.disabled = false;
      alert("しりとりファイターの負けです。");
      return;
    }

    judgeButton.disabled = false;
    alert("あなたのターン！");
  });
})();
</script>
```

書いた本人が言うのもなんですが、リファクタリングのやりがいがありそうな臭いがとても強いですね。