プログラミング言語で自然言語の様に会話する方策 第2.5回

Copyright (C) 2024 Takym.

最初回前回今回→次回→最終回

概要

前回の記事では下記の様な簡単な会話文をプログラミング言語で表現できるか試みました。

Aさん「こんにちは世界!」
Bさん「こんにちは世界!」
Aさん「お名前をお伺いしてもよろしいでしょうか。」
Bさん「私の名前はBです。」
Aさん「さようなら . . .」

練習問題として「BさんがAさんの名前を尋ねる」にはどうすれば良いか出題したので、その模範解答を提示します。(本当は筆者が面倒臭がって最後まで書くのを怠けた形なのですが・・・。しかし、コミット履歴を確認すると、記事を執筆した翌日には模範解答を完成させていた様です。つまり、それまでブログの更新をサボっていました。すみません。)

会話文実行処理の改善

現状では下記の様に会話文毎に MakeDecision を呼び出す形になっています。

var ctx  = new RootContext("根幹");
var mcaA = new MyCustomAgent("A");
var mcaB = new MyCustomAgent("B");

var d0 = mcaA.MakeDecision(NullAgent.Instance, ctx,       NullDecision.Instance);
var d1 = mcaB.MakeDecision(mcaA,               ctx, d0 ?? NullDecision.Instance);
var d2 = mcaA.MakeDecision(mcaB,               ctx, d1 ?? NullDecision.Instance);
var d3 = mcaB.MakeDecision(mcaA,               ctx, d2 ?? NullDecision.Instance);

Console.WriteLine(d0);
Console.WriteLine(d1);
Console.WriteLine(d2);
Console.WriteLine(d3);

※前回の記事では、結果の表示には神クラスの機能を使って出力していましたが、今回は分かり易さを重視して System.Console を用いています。本質的な違いはありません。

今回も会話文を追加しますから、MakeDecisionConsole.WriteLine の呼び出しがその分だけ増加する事になります。しかし、これでは会話文を追加する度に実行処理を書き換えなければなりませんし、何よりとても冗長な表現です。プログラムの大きさも無駄に大きくなってしまいます。ということで、まずは、一回の会話呼び出しの共通部分を括り出して、繰り返し処理に書き直します。下記の様になります。

var ctx  = new RootContext("根幹");
var mcaA = new MyCustomAgent("A");
var mcaB = new MyCustomAgent("B");

var dec = mcaA.MakeDecision(NullAgent.Instance, ctx, NullDecision.Instance);
while (dec is not null) {
	Console.WriteLine(dec.ToString());
	dec = mcaB.MakeDecision(mcaA, ctx, dec);
	(mcaA, mcaB) = (mcaB, mcaA);
}

最初の一回だけは繰り返しの外側で呼び出す必要があります。MakeDecisionnull を返したら、会話の実行を停止します。ただし、空の意思決定オブジェクト(NullDecision)は会話の終了を意味しませんので、ご注意ください。これによって、副次的に、非効率な null 検証を除去する事ができました。戻り値の意思決定オブジェクトは、最初の MakeDecision も表示される様に while ブロック内の先頭に記述します。また、次のループへ移る前に mcaAmcaB を入れ替えて置きます。

※勿論、Console.WriteLine(dec.ToString());神クラスの機能を用いて、dec.Pray(); に置き換える事もできます。この方が簡潔ですが、ProgrammingLanguageTalking を参照していないと使う事ができないという欠点もあります。

練習問題の模範解答

会話文実行処理の改善も済みましたので、練習問題の模範解答の解説に移ります。下記の様な会話文を意味するコードに書き換えていきます。

Aさん「こんにちは世界!」
Bさん「こんにちは世界!」
Aさん「お名前をお伺いしてもよろしいでしょうか。」
Bさん「私の名前はBです。お名前をお伺いしてもよろしいでしょうか。」
Aさん「私の名前はAです。さようなら . . .」

現状では、相手から名前を聞かれ自分の名前を答えた時は、メッセージの返送先に NullAgent.Instance.MakeDecision を指定しています。つまり、返送を受け付けないという事です。

if (decision is AskNameDecision) {
	return new ReplyNameDecision(this.DisplayName, this, NullAgent.Instance.MakeDecision);
}

しかし、これではBさんがAさんの名前を聞いた時に、無視されてしまう事になります。なので、下記の様に decision.SendMessage を指定する様にします。ここで生成される ReplyNameDecisiondecision.SendMessage の戻り値となり、Bさんの発言ではありますが、最終的には、Aさんの意思決定として扱われます。よって、返送先には相手方の MakeDecision 関数を指定しなければなりません。相手方の MakeDecision 関数は decision.SendMessage から取得できます。

if (decision is AskNameDecision) {
	return new ReplyNameDecision(this.DisplayName, this, decision.SendMessage);
}

現状では、名前の回答を受けたら、お別れをする事になっています。

if (decision is ReplyNameDecision) {
	return new GoodByeDecision(this, NullAgent.Instance.MakeDecision);
}

ここを書き換えて、名前を質問する様にしなければなりませんが、単純に戻り値を書き換えるだけでは、交互に名前を聞き合って無限ループに陥ってしまいます。相手の名前は一度知る事ができればそれで充分な筈です。下記の出会いの挨拶を参考にしてみましょう。

if (decision is HelloWorldDecision) {
	if (context[nameof(HelloWorldDecision)] is null) {
		context[nameof(HelloWorldDecision)] = decision;
		goto HelloWorldDecision;
	}

	return decision.SendMessage(this, context, new AskNameDecision(this, this.MakeDecision));
}

一度やり取りがあった事を文脈情報に保存しています。文脈情報を確認する事で、同じ返答を繰り返さない様に制御しています。名前回答を受け取った時も同じ様にできる筈です。下記の様に書き換わりました。

if (decision is ReplyNameDecision) {
	if (context[nameof(ReplyNameDecision)] is null) {
		context[nameof(ReplyNameDecision)] = decision;
		return decision.SendMessage(this, context, new AskNameDecision(this, this.MakeDecision));
	}
	return new GoodByeDecision(this, NullAgent.Instance.MakeDecision);
}

これで本質的なコードは完成しました。下記に OnMessageReceived の全体をコメント付きで掲示します。

protected override Decision? OnMessageReceived(Agent sender, Context context, Decision decision)
{
	if (decision is GoodByeDecision) {
		// 別れた後は何も言わない。
		return null;
	}

	if ((sender, context, decision).IsTalkOrigin()) {
		// 会話が始まったので挨拶しよう。
		goto HelloWorldDecision;
	}

	if (decision is HelloWorldDecision) {
		// 相手が「こんにちは世界!」と言って来た。

		if (context[nameof(HelloWorldDecision)] is null) {
			// 自分はまだ出会いの挨拶をしていない。

			// 出会いの挨拶を言い合った事を予め覚えておく。
			context[nameof(HelloWorldDecision)] = decision;

			// 相手に挨拶を返してあげよう。
			goto HelloWorldDecision;
		}

		// 挨拶を交わしたら、相手の名前を聞く。
		// 日本語訳:「お名前をお伺いしてもよろしいでしょうか。」
		return decision.SendMessage(this, context, new AskNameDecision(this, this.MakeDecision));
	}

	if (decision is AskNameDecision) {
		// 名前を聞かれたら、素直に答える。
		// 日本語訳:「私の名前は○○です。」
		return new ReplyNameDecision(this.DisplayName, this, decision.SendMessage);
	}

	if (decision is ReplyNameDecision) {
		// 相手に名前を伝えた。

		if (context[nameof(ReplyNameDecision)] is null) {
			// まだ自分は相手の名前を聞いていない。

			// 名前を教え合った事を予め覚えておく。
			context[nameof(ReplyNameDecision)] = decision;

			// 相手の名前を聞く。
			// 日本語訳:「お名前をお伺いしてもよろしいでしょうか。」
			return decision.SendMessage(this, context, new AskNameDecision(this, this.MakeDecision));
		}

		// 名前を交換したら、名残惜しいが別れる。
		// 日本語訳:「さようなら . . .」
		return new GoodByeDecision(this, NullAgent.Instance.MakeDecision);
	}

	// 全く予期しない事が言われた場合は、
	// 取り敢えず会話を終了させる。
	return null;

HelloWorldDecision:
	// 日本語訳:「こんにちは世界!」
	return new HelloWorldDecision(this, this.MakeDecision);
}

尚、デバッグのし易さを向上する為に ReplyNameDecision の表示名に MyCustomAgent の表示名を含む様に書き換えました。

public sealed class ReplyNameDecision(string? agentName, MyCustomAgent receiver, SendMessageFunc sendMessage)
	: Decision(string.IsNullOrEmpty(agentName) ? "名前回答" : $"名前回答「{agentName}」", receiver, sendMessage)
{
	public string? AgentName => agentName;
}

更なる発展

前回の記事の練習問題の解答は完成しました。ここからもう少し発展させましょう。実は上記のコードには無駄があります。名前質問を行ってから受け取った名前回答を呼び出し元へ返す処理が重複して記述されています。HelloWorldDecision と同じく goto 文を使って共通化できます。また、折角、知った相手の名前を MyCustomAgent のフィールド変数として保持しておきましょう。更にもう一つ、相手が別れの挨拶をした時に、自分からも別れの挨拶を返す様にしてみましょう。これらの編集を加えた OnMessageReceived は下記の様になります。

// 相手の名前を覚えて置くフィールド変数の定義(volatile は無くても良い)
private volatile ReplyNameDecision? _other_name;

protected override Decision? OnMessageReceived(Agent sender, Context context, Decision decision)
{
	if ((sender, context, decision).IsTalkOrigin()) {
		// 会話が始まったので挨拶しよう。
		goto HelloWorldDecision;
	}

	if (decision is HelloWorldDecision) {
		// 相手が「こんにちは世界!」と言って来た。

		if (context[nameof(HelloWorldDecision)] is null) {
			// 自分はまだ出会いの挨拶をしていない。

			// 出会いの挨拶を言い合った事を予め覚えておく。
			context[nameof(HelloWorldDecision)] = decision;

			// 相手に挨拶を返してあげよう。
			goto HelloWorldDecision;
		}

		// 挨拶を交わしたら、相手の名前を聞く。
		goto AskNameDecision;
	}

	if (decision is AskNameDecision) {
		// 名前を聞かれたら、素直に答える。
		goto ReplyNameDecision;
	}

	if (decision is ReplyNameDecision) {
		// 相手に名前を伝えた。

		if (context[nameof(ReplyNameDecision)] is null) {
			// まだ自分は相手の名前を聞いていない。

			// 名前を教え合った事を予め覚えておく。
			context[nameof(ReplyNameDecision)] = decision;

			// 相手の名前を聞く。
			goto AskNameDecision;
		}

		// 名前を交換したら、名残惜しいが別れる。
		goto GoodByeDecision;
	}

	if (decision is GoodByeDecision &&
		context[nameof(GoodByeDecision)] is null) {
		// 相手が別れの挨拶をしたが、自分はまだしていない。

		// 別れの挨拶を言い合った事を予め覚えておく。
		context[nameof(GoodByeDecision)] = decision;

		// 別れを受け入れて会話を終える。
		goto GoodByeDecision;
	}

	// 別れた後は何も言わない。
	// また、全く予期しない事が言われた場合にも、
	// 取り敢えず会話を終了させる。
	return null;

HelloWorldDecision:
	// 日本語訳:「こんにちは世界!」
	return new HelloWorldDecision(this, this.MakeDecision);

AskNameDecision:
	// 日本語訳:「お名前をお伺いしてもよろしいでしょうか。」
	return decision.SendMessage(this, context, new AskNameDecision(this, this.MakeDecision)) switch {
		// 相手の名前を覚える。
		ReplyNameDecision otherName => _other_name = otherName,

		// 相手が名前を教えてくれなかったら、そんな無礼な相手とは、そこで会話終了。
		_ => null
	};

ReplyNameDecision:
	// 日本語訳:「私の名前は○○です。」
	return new ReplyNameDecision(this.DisplayName, this, decision.SendMessage);

GoodByeDecision:
	// 日本語訳:「さようなら . . .」
	return new GoodByeDecision(this, NullAgent.Instance.MakeDecision);
}

コードを整理した結果、条件部分と返却部分を上手く分離する事ができました。前半が会話時の条件や流れを表し、後半が意思決定オブジェクトを生成し呼び出し元へ返す処理になっております。下記の日本語に相当する会話文を綺麗に表現する事ができました。

Aさん「こんにちは世界!」
Bさん「こんにちは世界!」
Aさん「お名前をお伺いしてもよろしいでしょうか。」
Bさん「私の名前はBです。お名前をお伺いしてもよろしいでしょうか。」
Aさん「私の名前はAです。さようなら . . .」
Bさん「さようなら . . .」

今回はここまでとします。今回の記事までの完全なソースコードは https://github.com/Takym/primers/tree/talking/2024-12-19/src/ProgrammingLanguageTalking で確認する事ができます。MITライセンスの下で配布していますので、ご注意ください。

最初回前回今回→次回→最終回