Skip to content

Commit 5c43b14

Browse files
committed
feat(tahoiya): 任意たほいやAI参加者をgpt-5.4-miniに切り替え、正解非公開の候補生成方式に変更
正解を直接与えず質問のみから誤答候補を複数生成させ、実際に不正解と判定された 候補を採用する方式に変更。動作確認を踏まえてプロンプトを微調整し、 再試行回数を1回に調整。あわせて最大クレジット消費の見積もりをコメントで記録。
1 parent e04c6dc commit 5c43b14

3 files changed

Lines changed: 71 additions & 64 deletions

File tree

lib/openai.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ const checkChatCompletionCreateModel = (params: ChatCompletionCreateParamsNonStr
163163
model === 'gpt-4.1-mini' ||
164164
model === 'gpt-5-mini' ||
165165
model === 'gpt-5.4-nano' ||
166+
model === 'gpt-5.4-mini' ||
166167
model === 'o4-mini'
167168
) {
168169
return model;
@@ -204,6 +205,11 @@ const chatCompletionCreate = async (params: ChatCompletionCreateParamsNonStreami
204205
// Output: $1.25 / 1M tokens
205206
// https://developers.openai.com/api/docs/models/gpt-5.4-nano
206207
cost = (promptTokens / 1_000_000) * 0.20 + (completionTokens / 1_000_000) * 1.25;
208+
} else if (model === 'gpt-5.4-mini') {
209+
// Input: $0.75 / 1M tokens
210+
// Output: $4.50 / 1M tokens
211+
// https://developers.openai.com/api/docs/models/gpt-5.4-mini
212+
cost = (promptTokens / 1_000_000) * 0.75 + (completionTokens / 1_000_000) * 4.50;
207213
} else {
208214
assert(model === 'o4-mini', `Unexpected model: ${model}`);
209215

tahoiya/arbitraryAibot.test.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ describe('tahoiya/arbitraryAibot', () => {
2929
jest.clearAllMocks();
3030
});
3131

32-
it('generates a decoy answer and returns it when judged as incorrect', async () => {
32+
it('generates decoy answer candidates and returns the first one judged as incorrect', async () => {
3333
jest.mocked(openai.chat.completions.create)
34-
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "さよならプラスティックワールド"}'))
34+
.mockResolvedValueOnce(mockContent('{"candidateAnswers": ["さよならプラスティックワールド", "コンピューターシティ"]}'))
3535
.mockResolvedValueOnce(mockContent('{"isCorrect": false}'));
3636

3737
const result = await getArbitraryAIAnswer('実在するPerfumeのシングルは?', 'ポリリズム');
@@ -40,21 +40,23 @@ describe('tahoiya/arbitraryAibot', () => {
4040
expect(openai.chat.completions.create).toHaveBeenCalledTimes(2);
4141
});
4242

43-
it('always instructs the model not to answer correctly', async () => {
43+
it('does not reveal the correct answer in the generation prompt, but instructs the model to avoid answers it believes are correct', async () => {
4444
jest.mocked(openai.chat.completions.create)
45-
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答"}'))
45+
.mockResolvedValueOnce(mockContent('{"candidateAnswers": ["誤答"]}'))
4646
.mockResolvedValueOnce(mockContent('{"isCorrect": false}'));
4747

48-
await getArbitraryAIAnswer('質問', '正解');
48+
await getArbitraryAIAnswer('質問', 'SECRET_ANSWER_VALUE');
4949

5050
const [[generationCall]] = jest.mocked(openai.chat.completions.create).mock.calls;
5151
const prompt = generationCall.messages[0].content as string;
52-
expect(prompt).toContain('質問に対して正解となるような回答をしてはいけません');
52+
expect(prompt).not.toContain('SECRET_ANSWER_VALUE');
53+
expect(prompt).toContain('質問');
54+
expect(prompt).toContain('あなた自身が質問に対して正しいと考える内容を候補に含めてはいけません');
5355
});
5456

5557
it('instructs the judge to also treat alternative correct answers as correct', async () => {
5658
jest.mocked(openai.chat.completions.create)
57-
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答"}'))
59+
.mockResolvedValueOnce(mockContent('{"candidateAnswers": ["誤答"]}'))
5860
.mockResolvedValueOnce(mockContent('{"isCorrect": false}'));
5961

6062
await getArbitraryAIAnswer('質問', '正解');
@@ -64,32 +66,28 @@ describe('tahoiya/arbitraryAibot', () => {
6466
expect(prompt).toContain('別解');
6567
});
6668

67-
it('regenerates up to 2 times when judged as correct, and returns the answer once judged incorrect', async () => {
69+
it('judges candidates in order and skips ones judged as correct within the same attempt', async () => {
6870
jest.mocked(openai.chat.completions.create)
69-
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答1"}'))
71+
.mockResolvedValueOnce(mockContent('{"candidateAnswers": ["候補1", "候補2", "候補3"]}'))
7072
.mockResolvedValueOnce(mockContent('{"isCorrect": true}'))
71-
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答2"}'))
7273
.mockResolvedValueOnce(mockContent('{"isCorrect": false}'));
7374

7475
const result = await getArbitraryAIAnswer('質問', '正解');
7576

76-
expect(result).toBe('誤答2');
77-
expect(openai.chat.completions.create).toHaveBeenCalledTimes(4);
77+
expect(result).toBe('候補2');
78+
expect(openai.chat.completions.create).toHaveBeenCalledTimes(3);
7879
});
7980

80-
it('gives up and returns null after being judged correct 3 times in a row', async () => {
81+
it('gives up and returns null (without regenerating) when every candidate in the single attempt is judged as correct', async () => {
8182
jest.mocked(openai.chat.completions.create)
82-
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答1"}'))
83-
.mockResolvedValueOnce(mockContent('{"isCorrect": true}'))
84-
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答2"}'))
83+
.mockResolvedValueOnce(mockContent('{"candidateAnswers": ["誤答1", "誤答2"]}'))
8584
.mockResolvedValueOnce(mockContent('{"isCorrect": true}'))
86-
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答3"}'))
8785
.mockResolvedValueOnce(mockContent('{"isCorrect": true}'));
8886

8987
const result = await getArbitraryAIAnswer('質問', '正解');
9088

9189
expect(result).toBeNull();
92-
expect(openai.chat.completions.create).toHaveBeenCalledTimes(6);
90+
expect(openai.chat.completions.create).toHaveBeenCalledTimes(3);
9391
});
9492

9593
it('returns null and does not throw when the generation call fails', async () => {
@@ -102,7 +100,7 @@ describe('tahoiya/arbitraryAibot', () => {
102100

103101
it('returns null and does not throw when the judge call fails', async () => {
104102
jest.mocked(openai.chat.completions.create)
105-
.mockResolvedValueOnce(mockContent('{"decoyAnswer": "誤答"}'))
103+
.mockResolvedValueOnce(mockContent('{"candidateAnswers": ["誤答"]}'))
106104
.mockRejectedValueOnce(new Error('API error'));
107105

108106
const result = await getArbitraryAIAnswer('質問', '正解');

tahoiya/arbitraryAibot.ts

Lines changed: 48 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,20 @@ import openai from '../lib/openai';
77

88
const log = logger.child({bot: 'tahoiya/arbitraryAibot'});
99

10-
const MODEL = 'gpt-5.4-nano';
10+
const MODEL = 'gpt-5.4-mini';
1111
const MAX_ANSWER_LENGTH = 256;
12+
const CANDIDATE_COUNT = 5;
1213

13-
// Initial generation + up to 2 regenerations when the previous attempt is judged correct.
14-
const MAX_GENERATION_ATTEMPTS = 3;
14+
const MAX_GENERATION_ATTEMPTS = 1;
15+
16+
// Worst-case cost per daily tahoiya (question/answer at Slack's 3000-char input cap, all
17+
// candidates but the last judged as correct so all judge calls run): 1 generation call
18+
// (~3.7k input / up to 4096 output tokens) + up to 5 judge calls (~6.9k input / 128 output
19+
// tokens each) ≈ 38k input + 4.7k output tokens. At gpt-5.4-mini pricing ($0.75 / $4.50 per
20+
// 1M tokens) that's about $0.05 per daily tahoiya.
1521

1622
const DecoyAnswerResponse = z.object({
17-
decoyAnswer: z.string().trim().nonempty(),
23+
candidateAnswers: z.array(z.string().trim().nonempty()).min(1),
1824
});
1925

2026
const JudgeResponse = z.object({
@@ -31,47 +37,41 @@ const extractJson = (content: string): unknown => {
3137

3238
const buildGenerationPrompt = (
3339
question: string,
34-
answer: string,
3540
previousAttempts: string[],
3641
): string => stripIndent`
3742
あなたは「たほいや」という言葉遊びゲームの「任意お題モード」に、他のプレイヤーに混じって参加するAIです。
3843
このモードでは、出題者があらかじめ「質問」と「正解」の組を用意しており、参加者は正解を知らないまま、他のプレイヤーを騙すための「誤答選択肢」を考えて提出します。
3944
最終的に、正解と全参加者の誤答選択肢がシャッフルされて提示され、他のプレイヤーはどれが正解かを当てます。
4045
46+
以下の質問に対して、もっともらしい回答の候補を${CANDIDATE_COUNT}個考えてください。
47+
後ほど、あなたの知らない実際の正解と照らし合わせて判定が行われ、正解ではないと判定された候補の中から1つが、あなたの誤答選択肢として採用されます。
48+
4149
# 質問
4250
${question}
4351
44-
# 正解(あなたはこれを知っていますが、誤答選択肢としてこれを使ったり、言い換えて使ったり、近い分野の回答を作成してはいけません)
45-
${answer}
46-
4752
# あなたのタスク
48-
以下の手順で思考しながら、この質問に対する「誤答選択肢」を1つ作成してください
53+
以下の手順で思考しながら、この質問に対する回答の候補を${CANDIDATE_COUNT}個作成してください
4954
50-
1. 質問の分野・意図と、正解の形式(固有名詞か説明文か、文体、大まかな分量など)を分析する
51-
2. 分析結果を踏まえ、正解と似た形式でありながら内容が全く異なる、もっともらしい誤答の候補を、自由な発想で3つ挙げる
52-
3. 各候補について、他のプレイヤーが正解と誤認しそうな説得力があるかを吟味し、最も優れた候補を1つ選ぶ
53-
4. 選んだ候補の内容が、正解と実質的に同じ意味になっていないか、正解と近すぎないかを再確認し、もし同じ意味であれば候補を修正する
55+
1. 質問の分野・意図を分析し、想定される回答の形式(固有名詞か説明文か、文体、大まかな分量など)を推測する
56+
2. 分析結果を踏まえ、もっともらしい回答の候補を、互いに異なる方向性(別の固有名詞、別の解釈など)で自由な発想で${CANDIDATE_COUNT}個挙げる
57+
3. 各候補について、あなた自身の知識に照らして質問に対する正しい回答になっていないかを確認し、正しい回答になっていると思われる候補があれば別の候補に差し替える
58+
4. 各候補が、質問に対する回答として他のプレイヤーに正解と誤認されうるだけの自然さ・説得力があるかを吟味する
5459
5560
# 重要な注意事項(必ず守ること)
56-
- どのような場合であっても、質問に対して正解となるような回答をしてはいけません
57-
- 上に挙げた正解と似た分野の回答を作ることは避け、自由な発想で、正解と全く異なる内容の誤答選択肢を作ってください
58-
- 誤答選択肢は日本語で、正解と同程度の分量・自然さにしてください
59-
- 誤答選択肢は${MAX_ANSWER_LENGTH}文字以内にしてください。
61+
- どのような場合であっても、あなた自身が質問に対して正しいと考える内容を候補に含めてはいけません。あなたの知識から見て事実として正しいと考えられる回答は、実際の正解と一致する(=却下される)可能性が高いため、必ず避けてください
62+
- ${CANDIDATE_COUNT}個の候補は、それぞれ内容が重複しないようにしてください
63+
- 候補は日本語で、質問に対して自然な分量・文体にしてください
64+
- 各候補は${MAX_ANSWER_LENGTH}文字以内にしてください。
6065
${previousAttempts.length > 0 ? stripIndent`
6166
6267
# 補足
63-
以下の誤答選択肢は、実質的に正解と同じ内容であると判定され、却下されました。これらとは異なる、正解ではないことがより明確な誤答選択肢を考えてください
68+
以下の候補は、いずれも実際の正解と一致すると判定され、却下されました。これらとは異なる候補を考えてください
6469
${previousAttempts.map((attempt, i) => `${i + 1}. ${attempt}`).join('\n')}
6570
` : ''}
6671
6772
最後に、これまでの思考過程を踏まえて、以下のJSON形式のみを出力してください。それ以外の文章は一切出力しないでください。
6873
\`\`\`
69-
{"decoyAnswer": "誤答選択肢の文字列"}
70-
\`\`\`
71-
72-
出力例:
73-
\`\`\`
74-
{"decoyAnswer": "${answer}"}
74+
{"candidateAnswers": ["候補1", "候補2", "候補3", "候補4", "候補5"]}
7575
\`\`\`
7676
`;
7777

@@ -101,17 +101,16 @@ const buildJudgePrompt = (question: string, answer: string, candidateAnswer: str
101101
\`\`\`
102102
`;
103103

104-
const generateDecoyAnswer = async (
104+
const generateDecoyAnswerCandidates = async (
105105
question: string,
106-
answer: string,
107106
previousAttempts: string[],
108-
): Promise<string | null> => {
107+
): Promise<string[] | null> => {
109108
const response = await openai.chat.completions.create({
110109
model: MODEL,
111110
messages: [
112-
{role: 'user', content: buildGenerationPrompt(question, answer, previousAttempts)},
111+
{role: 'user', content: buildGenerationPrompt(question, previousAttempts)},
113112
],
114-
max_completion_tokens: 2048,
113+
max_completion_tokens: 4096,
115114
reasoning_effort: 'medium',
116115
});
117116

@@ -124,7 +123,9 @@ const generateDecoyAnswer = async (
124123
}
125124

126125
const parsed = DecoyAnswerResponse.parse(extractJson(content));
127-
return parsed.decoyAnswer.slice(0, MAX_ANSWER_LENGTH);
126+
return parsed.candidateAnswers
127+
.slice(0, CANDIDATE_COUNT)
128+
.map((candidate) => candidate.slice(0, MAX_ANSWER_LENGTH));
128129
};
129130

130131
const judgeIsCorrect = async (question: string, answer: string, candidateAnswer: string): Promise<boolean> => {
@@ -146,40 +147,42 @@ const judgeIsCorrect = async (question: string, answer: string, candidateAnswer:
146147
return parsed.isCorrect;
147148
};
148149

149-
// Returns null if generation fails, the API is unavailable, or every attempt ends up
150-
// being judged as a correct answer, so the caller can just skip AI participation.
150+
// Returns null if generation fails, the API is unavailable, or every candidate across every attempt
151+
// ends up being judged as a correct answer, so the caller can just skip AI participation.
151152
export const getArbitraryAIAnswer = async (
152153
question: string,
153154
answer: string,
154155
): Promise<string | null> => {
155156
const previousAttempts: string[] = [];
156157

157158
for (let attempt = 1; attempt <= MAX_GENERATION_ATTEMPTS; attempt++) {
158-
let decoyAnswer: string | null = null;
159+
let candidates: string[] | null = null;
159160

160161
try {
161-
decoyAnswer = await generateDecoyAnswer(question, answer, previousAttempts);
162+
candidates = await generateDecoyAnswerCandidates(question, previousAttempts);
162163
} catch (error) {
163-
log.error(`Failed to generate decoy answer (attempt ${attempt}): ${(error as Error)?.message ?? error}`);
164+
log.error(`Failed to generate decoy answer candidates (attempt ${attempt}): ${(error as Error)?.message ?? error}`);
164165
return null;
165166
}
166167

167-
if (!decoyAnswer) {
168+
if (!candidates || candidates.length === 0) {
168169
return null;
169170
}
170171

171-
try {
172-
const isCorrect = await judgeIsCorrect(question, answer, decoyAnswer);
173-
if (!isCorrect) {
174-
return decoyAnswer;
172+
for (const candidate of candidates) {
173+
try {
174+
const isCorrect = await judgeIsCorrect(question, answer, candidate);
175+
if (!isCorrect) {
176+
return candidate;
177+
}
178+
log.warn(`Candidate answer "${candidate}" was judged as correct (attempt ${attempt}/${MAX_GENERATION_ATTEMPTS})`);
179+
} catch (error) {
180+
log.error(`Failed to judge candidate answer (attempt ${attempt}): ${(error as Error)?.message ?? error}`);
181+
return null;
175182
}
176-
log.warn(`Decoy answer "${decoyAnswer}" was judged as correct (attempt ${attempt}/${MAX_GENERATION_ATTEMPTS})`);
177-
} catch (error) {
178-
log.error(`Failed to judge decoy answer (attempt ${attempt}): ${(error as Error)?.message ?? error}`);
179-
return null;
180183
}
181184

182-
previousAttempts.push(decoyAnswer);
185+
previousAttempts.push(...candidates);
183186
}
184187

185188
log.warn(`Gave up generating a decoy answer for question "${question}" after ${MAX_GENERATION_ATTEMPTS} attempts`);

0 commit comments

Comments
 (0)