はじめに
Apple Foundation Models(以下AFM)のオンデバイスモデルを使って、自由入力のテキストアドベンチャーゲームを開発しています。
第1回では、デモ「血の池の夜」と、AFMに入力の解釈・自由会話・記憶の取り出しを任せ、物語の進行は台本とルールエンジンで制御する全体設計を紹介しました。
今回は、その中の「入力の解釈」を扱います。プレイヤーの自由な言葉を、ゲームが処理できる話題や移動に変換する部分です。
第1回の全体図に、今回扱う範囲を赤い太枠で示しました。台本に書いた話題から候補を作り、①入力の解釈とルールによる補正を経て、ルールエンジンで定型台詞を選ぶところまでを追います。
背景色は第1回と同じで、青がAFM、黄がルール、緑が台本データです。自由会話と記憶の詳しい実装は、第3回で扱います。
今回も、使うモデルはSystemLanguageModel.defaultです。入力の解釈は端末内で行い、Private Cloud Compute(PCC)への切り替えは行いません。
たとえば、プレイヤーが「流す曲を変えてみたら?」と入力したとき、それを店で流す音楽についての提案として扱い、台本の会話につなぎます。
流す曲を変えてみたら?
↓ 入力の解釈
意図:音楽
↓ ルールエンジン
条件を満たす台本の行を表示する
この記事では、まず台本データを確認し、候補の作成・絞り込み、構造化出力による選択、ルールによる補正、台詞の表示の順に説明します。ソースコードは次のリポジトリで公開しています。
掲載するコードは、公開デモの実装から必要な部分を抜粋したものです。実装の基準は第1回と同じ2026年10月10日時点で、macOS 27以降・Swift 6.2の構成を対象にしています。コード片の前後にある変数定義やエラー処理は、リンク先のファイルを参照してください。
1. 事前準備:scenario.jsonに話題・台詞・人物の知識を書く
まず、図の左上にある「台本(scenario.json)」を説明します。候補の作成や台詞の表示を理解するため、音楽の話題を例に、話題・定型台詞・人物の知識の3種類のデータを確認します。最後に、自由会話の生成が失敗した場合の定型台詞も紹介します。以下のJSONは、それぞれ台本内の該当箇所から抜き出したものです。
話題:何についての入力かを説明する
話題は、intents配列に定義します。音楽の話題は次のようになっています。
{
"id": "音楽",
"description": "店で流す音楽(昭和歌謡、映画音楽、カラオケなど)について意見を言う・尋ねる",
"examples": ["流す曲を変えてみたら?", "昭和歌謡は続けたほうがいいよ"],
"words": ["音楽", "曲", "歌", "BGM", "カラオケ", "レコード", "流す", "流して", "かける", "サントラ"]
}
idは話題の識別子、descriptionはモデルに読ませる説明、examplesはプレイヤーの言い方の例です。話題の名前だけでなく、何について意見を言ったり尋ねたりする話題なのかを説明します。
wordsは、話題の取り違えを減らすために、候補の絞り込みに使う語です。語を指定した話題は、入力にその語が一つも含まれていなければ、候補から外します。 一つでも含まれていれば候補に残りますが、必ずその話題が選ばれるわけではありません。wordsが空なら、この絞り込みは行いません。処理の詳細は第4節で説明します。
定型台詞:誰が、どの条件で、何を話すかを書く
話題の定義とは別に、場面ごとのlines配列に台詞を書きます。店内の場面には、音楽についての次の2行があります。
[
{
"id": "O1",
"speaker": "赤尾妙子",
"condition": "意図:音楽 + 済:A04",
"text": "音楽は昭和歌謡よ、決まってるじゃない。……葵ちゃんの映画音楽? あれ、眠くならない?"
},
{
"id": "O2",
"speaker": "神楽坂葵",
"condition": "直後:O1",
"effects": ["表情:神楽坂葵:怒り"],
"text": "失礼ね。夜に合うのよ、ああいうのが。"
}
]
idは台詞の識別子、speakerは話す人物のID、conditionは表示する条件、textはそのまま表示する本文です。effectsには演出を指定し、O2では葵の表情を「怒り」に変えます。
O1の意図:音楽 + 済:A04は、「入力が音楽の話題で、かつA04が表示済み」という条件です。A04は、葵の「……あなたはどう思う?」という台詞のIDです。O2の直後:O1は、「O1を表示した直後」を意味します。
ここでのA04やO1は台詞のIDです。後でモデルの選択用に付けるA1などの候補記号とは、別のものです。
人物の知識:自由会話で使う考えを書く
定型台詞がない場合や、同じ話題をもう一度振られた場合は、自由会話の返事を生成します。そのときに使う情報を、人物ごとのknowledge配列に書きます。たとえば、ママには次の情報を持たせています。
[
{
"info": "店では、自分で集めた昭和歌謡のレコードをかけている。若い人向けに、たまに違う曲を混ぜるくらいなら考えてもいい",
"condition": "意図:音楽"
},
{
"info": "真っ赤なドアは開店のときに自分で選んだ。外から中が見えないのが大人の店らしくて、開ける楽しみがあると思っている",
"condition": "意図:赤いドア"
}
]
infoが人物の知識や考え、conditionがその情報を使う条件です。ここでは、音楽の話題なら音楽についての考え、赤いドアの話題ならドアについての考えを、自由会話の生成に使います。infoは返事の参考情報であり、そのまま表示する定型台詞ではありません。
話題の定義があっても、対応する定型台詞があるとは限りません。赤いドアのように、この場面には定型台詞がなく、人物の考えを使った自由会話で受ける話題もあります。自由会話のプロンプトを組み立てる詳細は、第3回で扱います。
どの候補にも当たらない入力と、生成できなかった場合の定型台詞
どの候補にも当たらない入力は「なし」(コードでは.none)として扱い、通常は自由会話で返事を生成します。「どの候補にも当たらない」という理由だけで、決まった台詞を返す仕組みではありません。ただし、入力の話題に関係なく進む台本の行があれば、その行も表示します。
その自由会話で、生成のエラーや出力の検査によって使える返事を得られなかった場合に備え、人物ごとのfallbacksに定型台詞を書いておきます。たとえば、葵には次の2行を用意しています。
[
{"id": "神楽坂葵-拒1", "text": "……知らない。"},
{"id": "神楽坂葵-拒2", "text": "その話は、また今度ね。"}
]
これは「候補に当たらなかったときの台詞」ではなく、「自由会話の返事を生成できなかったときに戻す台詞」です。候補に当たっても、対応する定型台詞がなく自由会話に進んだ場合には、同じ仕組みを使います。返事の検査と、この定型台詞に戻す処理の詳細は、第3回で説明します。
実際の台本はscenario.json、データの書き方は台本のデータ仕様を参照してください。
2. 現在の状態から、選べる候補を作る
台本の定義を用意したら、図の「今の状態で選べる候補を作る」へ進みます。実際の場面でどんな候補が作られるかを確認します。
店内で「あなたはどう思う?」と聞かれたら
冒頭の会話に一度返事をし、葵の「……あなたはどう思う?」(A04)まで進んだとします。場所は店内で、ママと葵がいます。音楽とメニューの定型台詞は、まだ出ていません。
この状態では、次の10個を入力の候補にします。A1などは、AFMに選ばせるために付けた記号です。説明は読みやすいように要約しています。話し相手や入力に含まれる語による絞り込みは、まだ行っていません。
| 記号 | 入力の候補 | 説明の要約 | 候補の取得元 |
|---|---|---|---|
A1 |
意図:音楽 |
店で流す音楽について意見を言う・尋ねる | inputCandidates |
A2 |
意図:メニュー |
店のメニューや名物について意見を言う・尋ねる | inputCandidates |
A3 |
意図:二人の先の話 |
主人公と葵の仲の先行きや、貸し借りの話を持ち出す | inputCandidates |
A4 |
移動:控え室 |
控え室へ移動する | inputCandidates |
A5 |
離脱 |
今いる場所を離れる | inputCandidates |
A6 |
意図:赤いドア |
店の入口の赤いドアについて意見を言う・尋ねる | answeredIntents |
A7 |
意図:店名と看板 |
店名や看板について意見を言う・尋ねる | answeredIntents |
A8 |
意図:宣伝の写真 |
店の宣伝や載せる写真について意見を言う・尋ねる | answeredIntents |
A9 |
意図:いい店とは |
いい店や、自分の好きな店の話をする・尋ねる | answeredIntents |
A10 |
意図:今のままでいい |
何も変えず、今のままの店がいいと言う | answeredIntents |
たとえば、ここでママに「流す曲を変えてみたら?」と入力した場合は、A1、つまり「意図:音楽」を選んでもらい、ママと葵の音楽についての定型台詞につなぐ想定です。
次は、葵の問いかけに対して「流す曲を変えてみたら?」と入力し、送信する前の画面です。
一方、A6の「意図:赤いドア」には、この場面で表示する定型台詞の行が台本にありません。そのため、ルールエンジンは自由会話につなぎます。ただし、赤いドアについての人物の考えは台本の「知識」に書いてあるので、その情報をAFMに渡して、今の発言への返事を生成します。話題の定義や人物の知識があっても、対応する定型台詞があるとは限らない、という例です。
AFMに任せるのは、この候補からプレイヤーの発言に当てはまるものを選ぶことです。 候補にない話は「どれでもない」として受けます。台詞の選択や場所の移動は、選ばれた結果を受けてコードで処理します。
候補は、今の状態と人物の知識から作る
台本に書いたすべての話題や場所を、毎回モデルに渡すわけではありません。今いる場所や、すでに出た台詞などの状態を使います。
表の前半の5個は、GameRules.inputCandidates(in:)が返す候補です。台詞の条件を調べ、現在の状態で定型台詞を出せる話題と、行ける場所への移動、離脱を取り出します。
後半の5個は、answeredIntents(in:)から追加した候補です。この例では、その場にいる人物の知識に書かれ、この場面では定型台詞がない話題が入っています。定型台詞を話し終えた話題も、この方法で候補に残します。まだ出ていない台詞を前提とする話題などは除きます。
音楽の定型台詞を話し終えたからといって、「音楽」を候補から消すと、その後の「やっぱり映画音楽を流したほうがいいと思う」を、近い別の話題に取り違える可能性があります。候補に残しておけば、同じ音楽の話題として受け、人物の考えを渡した自由会話につなげられます。
answeredIntentsという名前ですが、一度話した話題だけが入るわけではありません。分類の候補は、定型台詞を出すためだけでなく、自由会話に必要な話題の情報を選ぶためにも使います。
候補をコードで表す:PlayerInput
ここまでの「意図:音楽」や「移動:控え室」を、コードでは次の型で表します。
PlayerInputのケースはnone、intent、move、leaveの4種類です。
public enum PlayerInput: Sendable, Hashable, CustomStringConvertible {
case none
case intent(String)
case move(String)
case leave
// descriptionと、台本での書き方から読み取るinitは省略
}
| 入力 | 意味 | 台本での書き方 |
|---|---|---|
.intent("音楽") |
音楽の話題として扱う | 意図:音楽 |
.move("控え室") |
控え室への移動として扱う | 移動:控え室 |
.leave |
今いる場所を離れようとする。行き先の一覧を出す | 離脱 |
.none |
どの候補にも当たらない | なし |
.leaveはゲームを終了する入力ではありません。このデモでは、移動先の一覧を出すために使います。
また、.noneは入力を捨てるという意味ではありません。通常は雑談として受けます。ただし、入力の話題に関係なく進む台本の行がある場合は、その行もルールに従って出ます。
自由な言い回しを受け取る部分と、ゲームの進行を決める部分は、この型でつないでいます。定義はGameState.swiftにあります。
3. 候補に記号と説明を付ける
図の「台本(scenario.json)」から得た情報を、「今の状態で選べる候補を作る」で組み合わせます。第2節で見た一覧を、モデルに渡すためのデータにする処理です。
InterpretChoices.makeでは、inputCandidates(in:)とansweredIntents(in:)の結果をつなぎ、各候補にA1、A2などの短い記号を付けます。
for (i, input) in (rules.inputCandidates(in: state) + rules.answeredIntents(in: state)).enumerated() {
let token = "A\(i + 1)"
// 話題の説明・発言例などを付けてOptionを作る
}
候補が持つ情報は、おおむね次のとおりです。
| 情報 | 役割 |
|---|---|
token |
モデルが出力する短い記号 |
input |
記号に対応するPlayerInput
|
label |
モデルが読む候補の説明 |
examples |
発言例 |
requiredWords |
候補を絞るための語 |
たとえば、第2節のA1は次のようなデータになります。label、examples、requiredWordsには、第1節の台本に書いた値が入ります。
InterpretChoices.Option(
token: "A1",
input: .intent("音楽"),
label: "店で流す音楽(昭和歌謡、映画音楽、カラオケなど)について意見を言う・尋ねる",
examples: ["流す曲を変えてみたら?", "昭和歌謡は続けたほうがいいよ"],
requiredWords: ["音楽", "曲", "歌", "BGM", "カラオケ", "レコード", "流す", "流して", "かける", "サントラ"]
)
モデルが返すのはA1という記号で、コードがその呼び出しの候補一覧を使って.intent("音楽")に戻します。モデルの呼び出しと結果の読み取りは、第5節で説明します。
状態が変わると、記号も変わる
音楽の定型台詞O1〜O2を表示した後は、音楽がinputCandidatesから外れ、answeredIntentsに入ります。このときも音楽は候補に残りますが、並びが変わり、記号はA5になります。次に選ばれたときは、同じ定型台詞を繰り返さず、自由会話につなげます。
状態によって候補の取得元や記号が変わっても、対応する入力は同じ.intent("音楽")です。 「A1はいつも音楽」とは扱わず、その呼び出しで使った候補一覧で対応付けます。
候補を作る処理はInputInterpreter.swiftのInterpretChoices.make、状態から入力を選ぶ処理はGameRules.swiftにあります。
4. 話し相手と「含む語」で候補を絞る
引き続き、図の「今の状態で選べる候補を作る」を扱います。ここでは、作った候補を絞り、「① AFM:入力の解釈(分類)」へ渡すまでを説明します。
状態から作った候補を、そのままAFMに渡す前に、二つの観点で絞ります。
4.1 話しかけた人物と、定型台詞の話者を合わせる
第1回でも触れましたが、店内にはママと葵がいます。誰に話しかけたかは、発言の内容だけでは決まらない場合があります。
そこで、文の頭での名前の呼びかけ、画面で選んだ相手、「葵はどう思う?」のような言い方をコードで判定します。
明示された相手が普段の応答者と違うときは、その相手以外が定型台詞で答える話題を候補から外します。たとえば、葵に向けた発言なのに、ママの定型台詞から会話が始まるのを避けます。
これは、話題そのものをすべて禁止する処理ではありません。その状態で定型台詞が出ない話題は、話しかけた人物の自由会話で扱えます。
話し相手の指定と候補の調整は、GameSession.swiftにあります。
4.2 関係する語がない話題を外す
次に、台本のwordsを使います。実装では、次の判定で、入力にどれかの語が含まれているかを調べます。
public func allows(_ text: String) -> Bool {
requiredWords.isEmpty || requiredWords.contains { text.contains($0) }
}
wordsが空なら制限なしです。語を指定している場合は、一つでも含まれていれば候補に残します。
候補の絞り込みと結果の補正を担当する、このデモ独自の型RefinedInterpreterでは、次のように処理します。innerはAFMによる解釈を担当する型で、第5節で説明します。Self.refineによる結果の補正は、第7節で扱います。
var narrowed = choices
narrowed.options = choices.options.filter { $0.allows(text) }
var result = try await inner.interpret(text, choices: narrowed)
result.input = Self.refine(result.input, text: text, choices: choices)
return result
「赤いドア」の話題なら、台本には「ドア」「扉」「入口」などを指定しています。それらの語が一つも含まれない発言を、この話題に分類させないようにします。
指定した語が含まれない候補は先に外し、残った候補から意味に合うものを選ぶ判断をAFMに任せる、という分担です。ただし、これは部分文字列による判定なので、語の選び方によっては、正しい言い換えまで外してしまう問題は残ります。候補を狭めるルール自体も、実際の入力で確かめる必要があります。
5. DynamicGenerationSchemaで出力を候補に制約する
図の「① AFM:入力の解釈(分類)」の内部です。候補をスキーマとプロンプトにし、モデルに記号を選ばせます。
この処理を担当するのが、デモで定義したSwiftの構造体FoundationModelsInterpreterです。Foundation Models frameworkのAPIを使って入力を分類するために、このゲーム側で用意した型です。InputInterpreter.swiftに実装しています。
interpret(_:choices:)でプレイヤーの発言と候補一覧(InterpretChoices)を受け取り、AFMに候補の記号を選ばせ、それに対応するPlayerInputと処理時間をまとめたInterpretationを返します。この節と次の第6節のコードは、この型の内部から抜粋しています。Self.noneやSelf.instructionsのSelfも、この型を指します。
候補を絞ったら、構造化出力のスキーマを作ります。
Foundation Models frameworkのguided generationは、期待する出力の形をプログラムで指定する仕組みです。実行時に形を組み立てる場合には、DynamicGenerationSchemaを使えます。Appleのguided generationの解説、DynamicGenerationSchemaのリファレンス
この実装では、choiceというプロパティに、候補の記号かnoneが入る形にします。
let root = DynamicGenerationSchema(name: "InputChoice", properties: [
.init(name: "choice", description: "選択肢の記号。当てはまらなければ none",
schema: DynamicGenerationSchema(name: "ChoiceToken", anyOf: choices.options.map(\.token) + [Self.none])),
])
let schema = try GenerationSchema(root: root, dependencies: [])
Self.noneは文字列の"none"です。たとえば、その呼び出しの候補がA1、A2、A3なら、choiceの値はそのいずれか、またはnoneになります。
{"choice": "A2"}
上は出力形式の例です。記号の意味は、先ほど作った候補の表で決まります。
プロンプトには、選択肢と今の場面を入れる
スキーマは出力できる値を制約しますが、どれを選ぶべきかの説明も必要です。
プロンプトには、候補の記号・説明・発言例、今いる場所、その場にいる人物、直前の相手の発言、プレイヤーの発言を入れます。説明用に候補を3つに絞ると、次のような形です。記号は例示用で、実際の候補一覧とは異なります。
## 選択肢
- A1: 店で流す音楽について意見を言う・尋ねる 例「流す曲を変えてみたら?」
- A2: 場所を移る:「控え室」へ行く
- A3: 今いる場所を離れる
## 今の場面
- 場所: 店内
- その場の人物: 赤尾妙子(ママ)、神楽坂葵(葵)
- 直前の相手の発言:「……あなたはどう思う?」
発言:「流す曲を変えてみたら?」
ここで、モデルへの指示と、分類する対象の情報を分けて渡します。instructionsは、LanguageModelSessionを作るときに指定する、モデルの役割や応答の方針です。この実装では「入力解析係として、発言がどの選択肢に当たるかを1つ選ぶ」という役割と、「発言例と同じ意味なら言い回しが違っても選ぶ」「選択肢にない話はnone」「迷ったらnone」といった分類の規則を書いています。
一方、上のプロンプトは、その呼び出しで分類するための入力です。現在の候補や場面、プレイヤーの発言をまとめ、respond(to:)に渡します。つまり、instructionsで「どう判断するか(HOW)」を指定し、プロンプトで「今回は何を判断するか(WHAT)」を渡す構成です。
直前の発言は、「うん」「そうしよう」などが何への返事かを判断する手がかりです。解釈用のセッションには、会話履歴を丸ごと渡しているわけではありません。
プレイヤーの発言をプロンプトに入れる際は、改行や括りの「」を置き換えます。入力の改行を使って、別の見出しや発言行を作られにくくするためです。これは入力を引用として扱う補助であり、プロンプトインジェクションを完全に防ぐ仕組みにはなっていません。
モデルを呼び、記号をPlayerInputへ戻す
呼び出しの中心部分は、次のとおりです。
let session = LanguageModelSession(model: model, instructions: Self.instructions)
let response = try await session.respond(to: prompt, schema: schema, options: GenerationOptions(temperature: 0))
Self.instructionsは、先ほど説明した役割と分類の規則を、この型の定数として定義したものです。promptには今回の候補と発言、schemaには返せる値の制約が入ります。
実際のコードでは、この呼び出しを時間制限とエラー処理でラップしています。オンデバイス処理でも、応答に時間がかかったり、長時間戻らなかったりする事例があったためです。返ってきたchoiceを文字列として読み取り、候補の表で対応する入力に戻します。
return try? response.content.value(String.self, forProperty: "choice")
let input = choices.options.first { $0.token == token }?.input ?? .none
temperatureで選択のばらつきを抑える
temperatureは、モデルが出力を選ぶ際のランダムさを調整する設定です。低くすると確率の高い出力を選びやすくなり、高くすると、それ以外の出力も選ばれやすくなります。「正確さ」や「賢さ」を直接指定する値ではありません。Appleのtemperatureの説明
今回は文章を多彩に生成する用途ではなく、候補の記号を1つ選ぶ分類なので、GenerationOptions(temperature: 0)を指定し、選択のばらつきを抑えています。スキーマが「何を出力できるか」を制約するのに対し、temperatureは「その中から選ぶ際のランダムさ」を調整するものです。
ただし、0にしても、意味の判断が正しいことや、すべての実行で同じ結果になることを保証するものではありません。候補の説明や並び、直前の発言が変われば、結果も変わり得ます。
構造化出力で制約するのは「出力できる値」です。その値が正しいかは、別に評価する必要があります。
6. コンテキストの上限を超えそうなら、発言例を外す
この節も、図の「① AFM:入力の解釈(分類)」の内部を扱います。モデルを呼ぶ前に、プロンプトの大きさを調整する処理です。
話題が増えると、説明と発言例だけでもプロンプトが大きくなります。
テスト環境の上限は8192トークンでしたが、実装では固定せず、モデルから実行時に取得しています。そのため、上限が4096や16384などの環境でも、その値に応じてプロンプトを調整します。ただし、削減後も収まらない場合があり、すべての環境での動作を保証するものではありません。渡せる情報量が変われば、分類結果にも差が出る可能性があります。
このデモでは、端末のモデルから取得したコンテキストの上限を使い、返答用に64トークンを空けて、入力に使える量を決めます。候補と発言例を含むプロンプトがその量を超える場合は、発言例を外して呼び出します。
let full = Self.prompt(text, choices: choices)
let used = await TokenCounter.count(Self.instructions + "\n" + full, model: model)
let fits = used <= budget.promptLimit
let prompt = fits ? full : Self.prompt(text, choices: choices, withExamples: false)
ここでは、候補の説明と今の場面を残し、補助情報である発言例を先に削ります。
ただし、このコードで数えるのはinstructionsとプロンプトのトークン数で、構造化出力のスキーマの分は含めていません。また、トークンを数える処理が失敗したり時間制限を超えたりした場合は、文字数をトークン数の概算値として使います。発言例を外した後も上限に収まらない可能性があるため、モデルの呼び出しにはエラー処理が必要です。解釈に失敗した場合の動作は、第8節で説明します。
コンテキストの予算と計数の実装は、ContextBudget.swiftにあります。自由会話では、上限に収まっていても、今の発言に集中させるために渡す履歴の量を絞っています。その理由と、さらに上限を超えたときにプロンプトを削る処理は、第3回で扱います。
7. モデルの解釈を、ルールで補正する
図の「規則で補正」に当たる処理です。「① AFM:入力の解釈(分類)」から返った結果を確かめ、必要に応じて修正します。
構造化出力を使っても、候補の取り違えは起きます。そこで、FoundationModelsInterpreterをRefinedInterpreterでラップし、モデルに渡す前の候補の絞り込みと、返ってきた結果の補正を行います。
アプリでは、次のように組み合わせています。
RefinedInterpreter(inner: interpreter)
interpreterはFoundationModelsInterpreterのインスタンスです。補正では、日本語全般を解析しようとせず、このゲームで取り違えが起きた言い方や、行動をはっきり示す言い方を扱っています。
7.1 発言例とほぼ同じ文は、その意図を優先する
台本に書いた発言例とほぼ同じ入力でも、モデルが近い別の候補を選ぶことがありました。
そこで、句読点などを除いた文字列を比較し、発言例と非常に近いときは、その意図を優先します。現在は、除去後の入力が6文字以上の場合に、最長共通部分列の長さが、比較する二つの文字列の長い方の90%以上になるかを見ています。最長共通部分列は、文字の順序を保ったまま取り出せる、最も長い共通の並びです。該当する意図が一つに決まる場合だけ補正します。
これは意味の類似度ではありません。言い換えの意味を拾うのはモデルに任せ、ほぼ同じ文字列が別の候補に取られるのをルールで防ぎます。
7.2 行き先と移動の言い方が明確なら、移動にする
「控え室に行こう」のように、行ける場所と移動の言い方が明確な入力は、モデルが別の候補を選んでも移動に補正します。
場所の別名も台本から読みます。「控室」「奥」は控え室の別名です。控え室から「ママに会いに行こう」と言った場合も、ママのいる店内へ移動する入力として扱います。
一方、「控え室には行ったことある?」のような質問は、場所と「行った」という語があっても、それだけで移動へ補正しません。ただし、これはモデルが誤って選んだ移動を、質問なら必ず取り消すという保証ではありません。すでにいる場所への移動は、GameSessionでモデルを呼ぶ前に判定し、今いる場所だと知らせます。
7.3 誘う言い方だけで、勝手に移動させない
開発中には、次の入力で控え室への移動が選ばれたことがありました。
葵、今度一緒に映画見ようよ
候補を絞った結果、話題の候補が少なくなり、誘う言い方が移動に取られた例です。この入力には、場所の名前も移動を示す言葉もありません。
そこで、モデルが移動を選んだ場合も、行き先や移動の手がかりを確認します。手がかりがない場合は.noneに戻します。
この補正は、実際の失敗を再現するユニットテストにしています。次はInterpretTests.swiftからの抜粋です。shopは、店内から控え室へ行ける候補を持つテスト用の変数です。
#expect(RefinedInterpreter.refine(.move("控え室"), text: "葵、今度一緒に映画見ようよ", choices: shop) == .none)
モデルが誤った移動を返した状態を直接作り、補正がその結果を止めるかを確認します。このテスト自体には、モデルを呼ぶ必要がありません。
7.4 短い同意は、直前の発言と合わせる
場所を言わずに「うん、行ってみよう」と答える場合もあります。
直前の相手が「続きは控え室で話さない?」と誘っていれば、その場所への移動として補正できます。行き先の手がかりがなければ、「行ってみよう」だけで勝手に移動先を決めないようにしています。
別れの挨拶も、発言の末尾に「失礼します」「お邪魔しました」などがあるかを見て、離脱へ補正します。「失礼しますって言うの?」のような質問まで離脱にしないための判定も入れています。
こうしたルールには限界があり、すべての言い回しに対応できるわけではありません。手直ししたら、元の失敗例に加えて、似ているが行動させたくない入力も確認しています。
8. 解釈済みの入力を、台本の条件につなぐ
図の「規則で補正」から「ルールエンジン」を通り、「定型文がある」へ進む経路を追います。左上の「台本(scenario.json)」に書いた条件が、ここで台詞の選択に使われます。
補正を終えた結果は、GameSessionで、話し相手による調整後の候補に含まれるかをもう一度確認します。ここで照合するのは、wordsによる絞り込み前の候補です。wordsに合うかどうかは、前述のRefinedInterpreterが扱います。
input = choices.options.contains { $0.input == result.input } ? result.input : .none
その後、ルールエンジンのGameRules.handleへ渡します。
let step = rules.handle(input, in: state)
第2節と同じく、店内で葵から「あなたはどう思う?」と聞かれた後に、ママに「流す曲を変えてみたら?」と入力し、「意図:音楽」になったとします。第1節で用意したO1の条件意図:音楽 + 済:A04は、入力が音楽で、A04も表示済みなので成立します。
O1の条件を満たすと、まずママの台詞を出します。続いて、直後:O1を条件とするO2を表示し、effectsの指定で葵の表情を「怒り」に変えます。プレイヤーがもう一度入力しなくても、台本の条件によって2人の会話が続きます。
O1 ママ:音楽は昭和歌謡よ、決まってるじゃない。……葵ちゃんの映画音楽? あれ、眠くならない?
O2 葵:失礼ね。夜に合うのよ、ああいうのが。
AFMが選ぶのは音楽という話題までです。どの台詞を出すか、誰が続けて話すか、表情をどう変えるかは、台本とルールエンジンが決めます。
O1〜O2を出し終えた後も、音楽の意図は候補に残ります。もう一度選ばれた場合、同じ定型台詞は出さず、音楽についての人物の考えを使った自由会話につなぎます。
解釈が失敗したときの動作も決めておく
ガードレールによる拒否や時間制限などで解釈に失敗した場合は、GameSessionがエラーの種類を記録し、.noneとして処理を続けます。ゲームの状態をモデルの成功だけに依存させないためです。
ただし、解釈の失敗と、モデルが正常にnoneを選んだ場合は、原因が違います。デバッグ画面やプレイログでは、候補、モデルの選択、解釈のエラーなどを確認し、分類の間違いか、呼び出しの失敗かを分けて調べます。
9. 動かして確かめる
ここまで説明した、図の候補の作成から定型台詞の表示までを確かめます。AFMによる分類と、ルールエンジンによる台本の進行を分けて確認します。
ソースからデバッグ版のアプリを起動すると、「開発」メニューで解釈の情報を確認できます。入力の表現を変えたとき、どの候補が選ばれ、定型台詞と自由会話のどちらへつながるかを試せます。
次の画面では、右側に直前の入力、解釈結果の「意図:音楽」、表示した定型台詞のID「O1、O2」が出ています。「今の解釈の候補」は現在の状態から作られる一覧なので、台詞の表示後は、先ほど解釈に使った候補と並びが変わっています。
CLIでも確認できます。モデルを使わず台本の進行だけを確かめるtraceと、モデルによる解釈を評価するeval-interpretを用意しています。リポジトリのルートで実行します。
swift build --package-path Yukemuri
# 解釈済みの入力を渡し、台本とルールエンジンだけを確かめる
./Yukemuri/.build/debug/yukemuri trace なし 意図:音楽 移動:控え室
# 実機のモデルで、発言と期待する解釈の組を評価する
./Yukemuri/.build/debug/yukemuri eval-interpret Yukemuri/Evaluation/interpret.json --repeat 3
traceの最初のなしは、冒頭の会話に一度返事をする操作に相当します。これで葵の「あなたはどう思う?」(A04)まで進めてから、意図:音楽を渡します。この順なら、本文で紹介したO1〜O2の定型台詞を確認できます。
traceで台本の結果を確認できても、自由入力を正しく分類できたことにはなりません。逆に、解釈が正しくても、台本の条件に抜けがあれば意図した会話は出ません。二つを分けて確認できる構成にしています。
また、発言例とほぼ同じ文は補正で正解になるため、それだけを評価しても、モデルが言い換えに対応できたかは分かりません。発言例と違う表現でも確かめることが必要です。評価データの作り方と、回帰テストの運用は、第4回以降で説明します。
10. 次回:登場人物の会話と記憶を作る
今回は、自由入力を台本の選択肢につなぐ部分を紹介しました。現在の状態から候補を作り、構造化出力で選択結果の形を制約し、取り違えをルールで補正してから、台本の条件へ渡しています。
次回は、定型台詞で受けない会話の返事をどう作るかを扱います。人物の設定や話題ごとの考えをプロンプトに入れる方法、プレイヤーについての記憶を聞いた人物にだけ渡す方法、生成された返事の検査と作り直しを、実装に沿って説明します。


