@@ -7,14 +7,20 @@ import openai from '../lib/openai';
77
88const log = logger . child ( { bot : 'tahoiya/arbitraryAibot' } ) ;
99
10- const MODEL = 'gpt-5.4-nano ' ;
10+ const MODEL = 'gpt-5.4-mini ' ;
1111const 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
1622const DecoyAnswerResponse = z . object ( {
17- decoyAnswer : z . string ( ) . trim ( ) . nonempty ( ) ,
23+ candidateAnswers : z . array ( z . string ( ) . trim ( ) . nonempty ( ) ) . min ( 1 ) ,
1824} ) ;
1925
2026const JudgeResponse = z . object ( {
@@ -31,47 +37,41 @@ const extractJson = (content: string): unknown => {
3137
3238const 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
130131const 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.
151152export 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