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

Copyright (C) 2024 Takym.

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

概要

前回の記事ではソースコードの注意点(MITライセンスの下で配布される事、記事内に全コードを掲載しない旨)と基本構造を説明しました。今回は簡単な会話を表現できるか試みます。少しずつコードを噛み砕いて説明していきます。尚、会話と言っても、あくまで人間同士がプログラミング言語を用いて、文章で会話する事を想定しております。コンピュータ同士の会話やコードの日本語への翻訳を行ったりする訳ではありません。機械学習の技術でもありません。因みに、タグ名やソースコードの題名として既に入れているので、気が付いている方もいらっしゃるかもしれませんが、当連載の略称は「プログラミング言語会話」です。

プロジェクト構成

プロジェクトを基本構造関連のプロジェクト(ProgrammingLanguageTalking.Core例文データ保管用プロジェクト(ProgrammingLanguageTalking.Examplesアプリ起動用プロジェクト(ProgrammingLanguageTalkingの三つに分割しました。本当は前回の時点で分割されていたのですが、重要ではないと判断したので説明を省きました。今回のソースコードは主に ProgrammingLanguageTalking.Examples に格納されています。

MyCustomAgent

会話を記述するには、Agent の派生クラスを作る必要があります。取り敢えず MyCustomAgent とでも名付けておきます。いかにもソースコードの例らしき意味の無い名前ですね。今回は複雑な会話を書きませんので、適当な仮名で充分です。もしかすると今後は改名するかもしれません。一先ず、下記の様な単純なコードになります。

public class MyCustomAgent : Agent
{
	public override string? DisplayName { get; }

	public MyCustomAgent(string? displayName)
	{
		this.DisplayName = displayName;
	}

	protected override Decision? OnMessageReceived(Agent sender, Context context, Decision decision)
	{
		return null;
	}
}

会話を始めるには何をすべきでしょうか。大抵は挨拶すると思います。プログラミングの学習での一般的な挨拶は「Hello, World!!」か「こんにちは世界!」ですが、プログラミング言語会話でも踏襲しましょう。OnMessageReceived 関数内で、新たに定義した HelloWorldDecision オブジェクトを返します。

protected override Decision? OnMessageReceived(Agent sender, Context context, Decision decision)
{
	// 日本語訳:「こんにちは世界!」
	return new HelloWorldDecision(this, this.MakeDecision);
}

public class HelloWorldDecision : Decision
{
	public HelloWorldDecision(MyCustomAgent receiver, SendMessageFunc sendMessage)
		: base("挨拶「Hello, World!!」", receiver, sendMessage) { }
}

やりましたね!これで出会いの挨拶を表現できました。今度は別れの挨拶を表現してみましょう。序でに、下記の様に出会いの挨拶と別れの挨拶を共通化しておきます。

public abstract class GreetDecision : Decision
{
	public string? Text { get; }

	public GreetDecision(string? text, MyCustomAgent receiver, SendMessageFunc sendMessage)
		: base(
			// 挨拶文を加工して、表示名を分かり易くする。
			// 渡された挨拶文が空でも意思決定オブジェクトの表示名は「挨拶」に設定する。
			string.IsNullOrEmpty(text)
				? "挨拶"
				: $"挨拶「{text}」",

			// その他の必要な引数を基底クラスの初期化子に渡す。
			receiver,
			sendMessage
		)
	{
		// 元の挨拶文を格納しておく。
		this.Text = text;
	}
}

// 出会いの挨拶。何故か英語で表現されている。
public sealed class HelloWorldDecision : GreetDecision
{
	public HelloWorldDecision(MyCustomAgent receiver, SendMessageFunc sendMessage)
		: base("Hello, World!!", receiver, sendMessage) { }
}

// 別れの挨拶。何故かこちらには「世界」が含まれていない。
public sealed class GoodByeDecision : GreetDecision
{
	public GoodByeDecision(MyCustomAgent receiver, SendMessageFunc sendMessage)
		: base("good-bye...", receiver, sendMessage) { }
}

そして、何時になったら別れの挨拶を言うのかというと、それは出会って直ぐです。何故ならこれ以外の言葉が実装されていないからです。プログラミング言語で表すと下記の様になります。

protected override Decision? OnMessageReceived(Agent sender, Context context, Decision decision)
{
	if (decision is HelloWorldDecision) {
		// 現実的に考えると悲しい事だが、出会ったら直ぐに別れる。
		// 日本語訳:「さようなら . . .」
		return new GoodByeDecision(this, NullAgent.Instance.MakeDecision);
	} else if (decision is GoodByeDecision) {
		// 別れた後は何も言わない。
		return null;
	} else {
		// 日本語訳:「こんにちは世界!」
		return new HelloWorldDecision(this, this.MakeDecision);
	}
}

GoodByeDecisionsendMessage には NullAgent.Instance.MakeDecision を設定しています。つまり、返送先が空になっています。別れた後は何を言われても聞かないという事ですね。よくよく考えると非常に冷たい態度ですね。ところで、上記のコードで「こんにちは世界!」と言うのは本当に会話が始まった時でしょうか。渡された意思決定オブジェクトが「こんにちは世界!」でも「さようなら . . .」でもない時に「こんにちは世界!」と言っています。もしかしたら他の事を言われたのに急に挨拶しているのかもしれません。論理的に考えるとおかしな事態に陥っています。どうにかして会話の開始を検出できる様にしなければなりません。送信者が誰も居なく、且つ渡された文脈が根文脈であり、且つ文脈の中身が空であり、且つ渡された意思決定オブジェクトが空である様な場合を会話の開始と定義付けましょう。下記の様な拡張関数を作れば検出する事ができます。

public static bool IsTalkOrigin(this (Agent sender, Context context, Decision decision) sendMessageArg)
{
	ArgumentNullException.ThrowIfNull(sendMessageArg.sender  );
	ArgumentNullException.ThrowIfNull(sendMessageArg.context );
	ArgumentNullException.ThrowIfNull(sendMessageArg.decision);

	return sendMessageArg.sender is NullAgent && sendMessageArg.context is RootContext && sendMessageArg.decision is NullDecision
		&& sendMessageArg.context.Decisions.IsEmpty;
}

ここで null 検証を行っているのは、OnMessageReceived の外部から呼び出される可能性もあるからです。分類が面倒だったので、この拡張関数は神クラス内に配置しました。神クラスは ProgrammingLanguageTalking.Core に格納されています。実際には God.DetectStartOfConversationGodExtensions.IsTalkOrigin に分かれていますが、上記のコードと本質的な違いはありません。会話の開始が検出できる様になりましたので、OnMessageReceived を整理します。

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

	if ((sender, context, decision).IsTalkOrigin()) {
		// 会話が始まったので挨拶しよう。
		// 日本語訳:「こんにちは世界!」
		return new HelloWorldDecision(this, this.MakeDecision);
	}

	if (decision is HelloWorldDecision) {
		// 現実的に考えると悲しい事だが、出会ったら直ぐに別れる。
		// 日本語訳:「さようなら . . .」
		return new GoodByeDecision(this, NullAgent.Instance.MakeDecision);
	}

	// 全く予期しない事が言われた場合は、
	// 取り敢えずこちらからは何も言わない。
	return null;
}

しかし、出会いの挨拶を言われて直ぐ別れるのは寂し過ぎるので、出会いの挨拶を言い返す様にしましょう。ただし、自分が挨拶を言って、相手から挨拶を言い返されたら別れる事にします。ここで文脈が役に立ちます。もし、文脈内に出会いの挨拶が含まれていなかったら、出会いの挨拶を返します。含まれていたら、別れの挨拶を返します。

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 new GoodByeDecision(this, NullAgent.Instance.MakeDecision);
	}

	// 全く予期しない事が言われた場合は、
	// 取り敢えずこちらからは何も言わない。
	return null;

	// goto 文とラベルを使うのを嫌っている人も多いが、
	// 今回の場合の様な単純な共通化には重宝すべき。
HelloWorldDecision:
	// 日本語訳:「こんにちは世界!」
	return new HelloWorldDecision(this, this.MakeDecision);
}

挨拶を言われたら挨拶を返す様になりましたが、その後は何も会話せずに別れてしまっています。この状況を日本語に翻訳すると下記の様になります。

Aさん「こんにちは世界!」
Bさん「こんにちは世界!」
Aさん「さようなら . . .」

お互いに相手を「世界」と呼び合っていますが、この際気にしない事にします。まだお互いの名前を知らないのです。挨拶が終わってから、相手の名前を聞けば良いのです。名前の質問と回答の意思決定オブジェクトを下記の様に設計しました。

public sealed class AskNameDecision(MyCustomAgent receiver, SendMessageFunc sendMessage)
	: Decision("名前質問", receiver, sendMessage);

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

「名前質問」(AskNameDecision)は型でのみ判定するのでとても単純な構造になっています。「名前回答」(ReplyNameDecision)は Agent の名前文字列を格納しているだけです。挨拶を済ませたら、相手に名前質問を投げて、名前質問を受けたら、名前回答を返します。名前回答を受け取ったらそのまま会話を終了します。

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, NullAgent.Instance.MakeDecision);
	}

	if (decision is ReplyNameDecision) {
		// 相手の名前を知ったら直ぐに別れる。
		// 自分だけ他人の名前を聞いておいて自分の名前を教えないのは失礼な気もするが・・・。
		// 日本語訳:「さようなら . . .」
		return new GoodByeDecision(this, NullAgent.Instance.MakeDecision);
	}

	// 全く予期しない事が言われた場合は、
	// 取り敢えずこちらからは何も言わない。
	return null;

	// goto 文とラベルを使うのを嫌っている人も多いが、
	// 今回の場合の様な単純な共通化には重宝すべき。
HelloWorldDecision:
	// 日本語訳:「こんにちは世界!」
	return new HelloWorldDecision(this, this.MakeDecision);
}

相手の名前を聞きに行くのに SendMessage を使っています。これは、意思決定オブジェクトを作成した人物にとって都合の良い場所へメッセージを送信する関数です。今回の例では全て MyCustomAgent.MakeDecision に設定されているので、「return new XxxxxDecision(...」とするのと変わりありません。相手に返却する名前には、MyCustomAgent の表示名を設定しています。ここまでの会話文を日本語の文章に直すと下記の様になります。

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

この続きの文章を書くとしたら、BさんがAさんの名前を尋ねるものになるでしょう。読者の方がご自身の手で考えながら書ける様に、今回は敢えてこの続きを書きません。決して筆者が面倒臭がった訳ではありません!

実行方法

勘の良い方は気が付いているかもしれませんが、勿論、このままでは動作しません。MyCustomAgent を生成して MakeDecision 経由で OnMessageReceived を呼び出さなければなりません。尚、MakeDecision から返却される意思決定オブジェクトの SendMessage を外部から呼び出してはいけません。SendMessageOnMessageReceived の内部で呼び出される事を前提に作られています。必要なオブジェクトは下記の様に初期化できます。

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

mcaAmcaBMakeDecision を交互に呼び出す事で会話全体を表現できます。初回の引数 sender には NullAgent.Instance を設定し、その後は mcaAmcaB を交互に設定します。mcaA.MakeDecisionsendermcaA 自身を設定するとエラーになりますので気を付けてください。mcaB の場合も同様です。意思決定オブジェクトは d0 ?? NullDecision.Instance の様に指定して null が渡される事の無い様にします。やり取りは 5 回ありますが、MakeDecision の呼び出しは 4 回で充分です。名前を聞く時に SendMessage を使っているからです。BさんがAさんの名前を尋ねる様に修正したら、MakeDecision の呼び出しを 1 回分だけ増やして 5 回にする必要があります。6 回ではありません。余分に増やしても何も起こらないだけで問題はありません。

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) で出力しても構いません。

d0?.ToString().Print();
d1?.ToString().Print();
d2?.ToString().Print();
d3?.ToString().Print();

DemoApp.Start に上記の会話を実行するコードの全文を入れました。MakeDecision を余分に呼び出しても何も起きない事を確認するコードも入れてあります。その他の余計なコードも入っていますが、気にしなくて構いません。今回はここまでとします。今回の記事までの完全なソースコードは https://github.com/Takym/primers/tree/talking/2024-12-10/src/ProgrammingLanguageTalking で確認する事ができます。MITライセンスの下で配布していますので、ご注意ください。

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