Skip to content

Commit 8aa7c4f

Browse files
hakatashiclaude
andcommitted
feat(tahoiya): 任意たほいやモードにOpenAIベースのAI参加者を追加
デイリーたほいやの任意お題モードで、辞書たほいやと同様にtahoiyabotが 参加できるようにする。OpenAI(gpt-4.1-mini)でCoT形式のプロンプトを用いて 誤答選択肢を生成し、別の呼び出しで正解判定を行い、正解と判定された場合は 最大2回まで再生成する。isMimicryAllowedの設定によらず、常に正解を 答えないよう指示する。OpenAI API呼び出しが失敗してもゲームが継続できる ようエラーハンドリングを行う。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vn8UKMnHrk2KyCGhAbL4Ed
1 parent 9e1340b commit 8aa7c4f

3 files changed

Lines changed: 314 additions & 1 deletion

File tree

tahoiya/Tahoiya.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@ import type {SlackInterface} from '../lib/slack';
1717
import State from '../lib/state';
1818
import {getAIBotMeaning} from './aibot';
1919
import type {AIBotModel} from './aibot';
20+
import {getArbitraryAIAnswer} from './arbitraryAibot';
2021
import {calculateRatingDeltas} from './rating';
2122
import type {
2223
DailyGameState,
2324
GameComment,
2425
NormalGameState,
2526
TahoiyaState,
27+
ArbitraryTheme,
2628
DictionarySource,
2729
DictionaryTheme,
2830
ShuffledMeaning,
@@ -55,6 +57,8 @@ const DUMMY_SIZE_BASE = 4;
5557
const DAILY_TAHOIYA_MINIMUM_PARTICIPANTS = 3;
5658

5759
const AI_BOT_MODELS: AIBotModel[] = ['tahoiyabot-01', 'tahoiyabot-02'];
60+
const ARBITRARY_AI_BOT_ID = 'tahoiyabot-arbitrary';
61+
const ALL_AI_BOT_IDS: string[] = [...AI_BOT_MODELS, ARBITRARY_AI_BOT_ID];
5862

5963
export class Tahoiya extends ChannelLimitedBot {
6064
protected override wakeWordRegex = /^(?:||)$/;
@@ -583,6 +587,31 @@ export class Tahoiya extends ChannelLimitedBot {
583587
}).catch((err) => this.log.error('AI bot error (daily):', err));
584588
}
585589
}
590+
591+
// OpenAI-based AI participant submits a decoy answer for arbitrary themes in the background
592+
if (theme.theme.type === 'arbitrary') {
593+
const arbitraryTheme = theme.theme as ArbitraryTheme;
594+
595+
getArbitraryAIAnswer(
596+
arbitraryTheme.question,
597+
arbitraryTheme.answer,
598+
arbitraryTheme.isMimicryAllowed ?? false,
599+
).then((decoyAnswer) => {
600+
if (!decoyAnswer) {
601+
return;
602+
}
603+
mutex.runExclusive(() => {
604+
if (this.#state.dailyGame?.themeId !== theme.id) {
605+
return;
606+
}
607+
if (this.#state.dailyGame.phase !== 'collect_meanings') {
608+
return;
609+
}
610+
611+
this.#state.dailyGame.meanings[ARBITRARY_AI_BOT_ID] = normalizeMeaning(decoyAnswer);
612+
});
613+
}).catch((err) => this.log.error('Arbitrary AI bot error (daily):', err));
614+
}
586615
}
587616

588617
async #triggerDailyBetting() {
@@ -1246,7 +1275,7 @@ export class Tahoiya extends ChannelLimitedBot {
12461275
return;
12471276
}
12481277

1249-
for (const botId of AI_BOT_MODELS) {
1278+
for (const botId of ALL_AI_BOT_IDS) {
12501279
if (!game.meanings[botId]) {
12511280
continue;
12521281
}

tahoiya/arbitraryAibot.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/* eslint-env jest */
2+
3+
import type {ChatCompletion} from 'openai/resources/chat';
4+
import openai from '../lib/openai';
5+
import {getArbitraryAIAnswer} from './arbitraryAibot';
6+
7+
process.env.OPENAI_API_KEY = 'test-api-key';
8+
9+
jest.mock('../lib/openai', () => ({
10+
__esModule: true,
11+
default: {
12+
chat: {
13+
completions: {
14+
create: jest.fn(),
15+
},
16+
},
17+
},
18+
}));
19+
20+
const mockContent = (content: string) => ({
21+
choices: [{
22+
message: {content},
23+
}],
24+
model: 'gpt-4.1-mini',
25+
} as ChatCompletion);
26+
27+
describe('tahoiya/arbitraryAibot', () => {
28+
beforeEach(() => {
29+
jest.clearAllMocks();
30+
});
31+
32+
it('generates a decoy answer and returns it when judged as incorrect', async () => {
33+
jest.mocked(openai.chat.completions.create)
34+
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "さよならプラスティックワールド"}'))
35+
.mockResolvedValueOnce(mockContent('{"isCorrect": false}'));
36+
37+
const result = await getArbitraryAIAnswer('実在するPerfumeのシングルは?', 'ポリリズム', false);
38+
39+
expect(result).toBe('さよならプラスティックワールド');
40+
expect(openai.chat.completions.create).toHaveBeenCalledTimes(2);
41+
});
42+
43+
it('always instructs the model not to answer correctly, regardless of isMimicryAllowed', async () => {
44+
jest.mocked(openai.chat.completions.create)
45+
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答"}'))
46+
.mockResolvedValueOnce(mockContent('{"isCorrect": false}'));
47+
48+
await getArbitraryAIAnswer('質問', '正解', true);
49+
50+
const [[generationCall]] = jest.mocked(openai.chat.completions.create).mock.calls;
51+
const prompt = generationCall.messages[0].content as string;
52+
expect(prompt).toContain('質問に対して正解となるような回答をしてはいけません');
53+
});
54+
55+
it('regenerates up to 2 times when judged as correct, and returns the answer once judged incorrect', async () => {
56+
jest.mocked(openai.chat.completions.create)
57+
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答1"}'))
58+
.mockResolvedValueOnce(mockContent('{"isCorrect": true}'))
59+
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答2"}'))
60+
.mockResolvedValueOnce(mockContent('{"isCorrect": false}'));
61+
62+
const result = await getArbitraryAIAnswer('質問', '正解', false);
63+
64+
expect(result).toBe('誤答2');
65+
expect(openai.chat.completions.create).toHaveBeenCalledTimes(4);
66+
});
67+
68+
it('gives up and returns null after being judged correct 3 times in a row', async () => {
69+
jest.mocked(openai.chat.completions.create)
70+
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答1"}'))
71+
.mockResolvedValueOnce(mockContent('{"isCorrect": true}'))
72+
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答2"}'))
73+
.mockResolvedValueOnce(mockContent('{"isCorrect": true}'))
74+
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答3"}'))
75+
.mockResolvedValueOnce(mockContent('{"isCorrect": true}'));
76+
77+
const result = await getArbitraryAIAnswer('質問', '正解', false);
78+
79+
expect(result).toBeNull();
80+
expect(openai.chat.completions.create).toHaveBeenCalledTimes(6);
81+
});
82+
83+
it('returns null and does not throw when the generation call fails', async () => {
84+
jest.mocked(openai.chat.completions.create).mockRejectedValueOnce(new Error('API error'));
85+
86+
const result = await getArbitraryAIAnswer('質問', '正解', false);
87+
88+
expect(result).toBeNull();
89+
});
90+
91+
it('returns null and does not throw when the judge call fails', async () => {
92+
jest.mocked(openai.chat.completions.create)
93+
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答"}'))
94+
.mockRejectedValueOnce(new Error('API error'));
95+
96+
const result = await getArbitraryAIAnswer('質問', '正解', false);
97+
98+
expect(result).toBeNull();
99+
});
100+
101+
it('returns null when the response does not contain valid JSON', async () => {
102+
jest.mocked(openai.chat.completions.create).mockResolvedValueOnce(mockContent('申し訳ありませんが回答できません'));
103+
104+
const result = await getArbitraryAIAnswer('質問', '正解', false);
105+
106+
expect(result).toBeNull();
107+
});
108+
});

tahoiya/arbitraryAibot.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/* eslint-disable import/prefer-default-export */
2+
import {stripIndent} from 'common-tags';
3+
import {z} from 'zod';
4+
import logger from '../lib/logger';
5+
import openai from '../lib/openai';
6+
7+
const log = logger.child({bot: 'tahoiya/arbitraryAibot'});
8+
9+
const MODEL = 'gpt-4.1-mini';
10+
const MAX_ANSWER_LENGTH = 256;
11+
12+
// Initial generation + up to 2 regenerations when the previous attempt is judged correct.
13+
const MAX_GENERATION_ATTEMPTS = 3;
14+
15+
const DecoyAnswerResponse = z.object({
16+
decoyAnswer: z.string().trim().nonempty(),
17+
});
18+
19+
const JudgeResponse = z.object({
20+
isCorrect: z.boolean(),
21+
});
22+
23+
const extractJson = (content: string): unknown => {
24+
const match = content.match(/\{[\s\S]*\}/);
25+
if (!match) {
26+
throw new Error(`No JSON object found in response: ${content}`);
27+
}
28+
return JSON.parse(match[0]);
29+
};
30+
31+
const buildGenerationPrompt = (
32+
question: string,
33+
answer: string,
34+
isMimicryAllowed: boolean,
35+
previousAttempts: string[],
36+
): string => stripIndent`
37+
あなたは「たほいや」という言葉遊びゲームの「任意お題モード」に、他のプレイヤーに混じって参加するAIです。
38+
このモードでは、出題者があらかじめ「質問」と「正解」の組を用意しており、参加者は正解を知らないまま、他のプレイヤーを騙すための「誤答選択肢」を考えて提出します。
39+
最終的に、正解と全参加者の誤答選択肢がシャッフルされて提示され、他のプレイヤーはどれが正解かを当てます。
40+
41+
# 質問
42+
${question}
43+
44+
# 正解(あなたはこれを知っていますが、誤答選択肢としてこれを使ったり、言い換えて使ったりしてはいけません)
45+
${answer}
46+
47+
# あなたのタスク
48+
以下の手順で思考しながら、この質問に対する「誤答選択肢」を1つ作成してください。
49+
50+
1. 質問の分野・意図と、正解の形式(固有名詞か説明文か、文体、大まかな分量など)を分析する。
51+
2. 分析結果を踏まえ、正解と似た形式でありながら内容が異なる、もっともらしい誤答の候補を3つ挙げる。
52+
3. 各候補について、他のプレイヤーが正解と誤認しそうな説得力があるかを吟味し、最も優れた候補を1つ選ぶ。
53+
4. 選んだ候補の内容が、正解と実質的に同じ意味になっていないかを再確認し、もし同じ意味であれば候補を修正する。
54+
55+
# 重要な注意事項(必ず守ること)
56+
- どのような場合であっても、質問に対して正解となるような回答をしてはいけません。これはこのゲームの設定(正解を答えることが許可されているかどうか)に関わらず、常に守ってください。
57+
- 誤答選択肢は日本語で、正解と同程度の分量・自然さにしてください。
58+
- 誤答選択肢は${MAX_ANSWER_LENGTH}文字以内にしてください。
59+
${isMimicryAllowed ? '- このゲームでは人間の参加者が正解をそのまま登録することも許可されていますが、あなたは必ず誤答を作成してください。' : ''}
60+
${previousAttempts.length > 0 ? stripIndent`
61+
62+
# 補足
63+
以下の誤答選択肢は、実質的に正解と同じ内容であると判定され、却下されました。これらとは異なる、正解ではないことがより明確な誤答選択肢を考えてください。
64+
${previousAttempts.map((attempt, i) => `${i + 1}. ${attempt}`).join('\n')}
65+
` : ''}
66+
67+
最後に、これまでの思考過程を踏まえて、以下のJSON形式のみを出力してください。それ以外の文章は一切出力しないでください。
68+
\`\`\`
69+
{"decoyAnswer": "誤答選択肢の文字列"}
70+
\`\`\`
71+
`;
72+
73+
const buildJudgePrompt = (question: string, answer: string, candidateAnswer: string): string => stripIndent`
74+
あなたは「たほいや」という言葉遊びゲームにおける、回答の正解判定を行う審判です。
75+
以下の「質問」と「正解」に対して、「判定対象の回答」が正解として扱われるべきかどうかを判定してください。
76+
77+
# 質問
78+
${question}
79+
80+
# 正解
81+
${answer}
82+
83+
# 判定対象の回答
84+
${candidateAnswer}
85+
86+
表記ゆれ・言い回しの違い・言語の違いなどは無視し、意味内容が正解と実質的に同じであれば true 、
87+
正解とは異なる内容であれば false としてください。
88+
89+
以下のJSON形式のみを出力してください。それ以外の文章は一切出力しないでください。
90+
\`\`\`
91+
{"isCorrect": true または false}
92+
\`\`\`
93+
`;
94+
95+
const generateDecoyAnswer = async (
96+
question: string,
97+
answer: string,
98+
isMimicryAllowed: boolean,
99+
previousAttempts: string[],
100+
): Promise<string | null> => {
101+
const response = await openai.chat.completions.create({
102+
model: MODEL,
103+
messages: [
104+
{role: 'user', content: buildGenerationPrompt(question, answer, isMimicryAllowed, previousAttempts)},
105+
],
106+
max_tokens: 1024,
107+
});
108+
109+
const content = response?.choices?.[0]?.message?.content;
110+
if (!content) {
111+
log.warn('No content found in the decoy generation response');
112+
return null;
113+
}
114+
115+
const parsed = DecoyAnswerResponse.parse(extractJson(content));
116+
return parsed.decoyAnswer.slice(0, MAX_ANSWER_LENGTH);
117+
};
118+
119+
const judgeIsCorrect = async (question: string, answer: string, candidateAnswer: string): Promise<boolean> => {
120+
const response = await openai.chat.completions.create({
121+
model: MODEL,
122+
messages: [
123+
{role: 'user', content: buildJudgePrompt(question, answer, candidateAnswer)},
124+
],
125+
max_tokens: 256,
126+
});
127+
128+
const content = response?.choices?.[0]?.message?.content;
129+
if (!content) {
130+
throw new Error('No content found in the judge response');
131+
}
132+
133+
const parsed = JudgeResponse.parse(extractJson(content));
134+
return parsed.isCorrect;
135+
};
136+
137+
// Returns null if generation fails, the API is unavailable, or every attempt ends up
138+
// being judged as a correct answer, so the caller can just skip AI participation.
139+
export const getArbitraryAIAnswer = async (
140+
question: string,
141+
answer: string,
142+
isMimicryAllowed: boolean,
143+
): Promise<string | null> => {
144+
const previousAttempts: string[] = [];
145+
146+
for (let attempt = 1; attempt <= MAX_GENERATION_ATTEMPTS; attempt++) {
147+
let decoyAnswer: string | null = null;
148+
149+
try {
150+
decoyAnswer = await generateDecoyAnswer(question, answer, isMimicryAllowed, previousAttempts);
151+
} catch (error) {
152+
log.error(`Failed to generate decoy answer (attempt ${attempt}): ${(error as Error)?.message ?? error}`);
153+
return null;
154+
}
155+
156+
if (!decoyAnswer) {
157+
return null;
158+
}
159+
160+
try {
161+
const isCorrect = await judgeIsCorrect(question, answer, decoyAnswer);
162+
if (!isCorrect) {
163+
return decoyAnswer;
164+
}
165+
log.warn(`Decoy answer "${decoyAnswer}" was judged as correct (attempt ${attempt}/${MAX_GENERATION_ATTEMPTS})`);
166+
} catch (error) {
167+
log.error(`Failed to judge decoy answer (attempt ${attempt}): ${(error as Error)?.message ?? error}`);
168+
return null;
169+
}
170+
171+
previousAttempts.push(decoyAnswer);
172+
}
173+
174+
log.warn(`Gave up generating a decoy answer for question "${question}" after ${MAX_GENERATION_ATTEMPTS} attempts`);
175+
return null;
176+
};

0 commit comments

Comments
 (0)