Sandbox dla algorytmów chatbotowych

|
Robert
| komentarze | Poradniki Inne

Zanim przejdziemy do wstępu właściwego: Wpis wisiał od sierpnia br. (2022 dop.), publikuję go choć może być nieco nieaktualny w związku z nową publikacją ChatGPT. Właściwy: Słuchając jednego z podcastów Krzysztofa Kempińskiego którego gość, Adam Matysiak opowiadał o różnego rodzaju chatbotach przypomniały mi się czasy IRC. Kiedyś, dawno temu (15 lat!) jednym z dość popularnych (lecz jednym z płatnych) klientów IRC na platformę Windows był mIRC. Był to dość specyficzny klient bowiem oferował w swoim zakresie środowisko uruchomieniowe swoich skryptów które o ile dobrze pamiętam były nieco podobne w składni do lua czy też trochę do języka powłoki sh.

TL;DR: AjaxChat jako środowisko do testowania swojego kodu i algorytmów w rozmowie z botem.

Z tamtych czasów pamiętam, jak wykonywałem różne rozszerzenia do mIRC w związku z tym, że dawał on możliwość skonfigurowania skryptu tak aby nadać mu również tytuł oraz dedykowany button lub nawet cały toolbar. Wykorzystując tą możliwość, pamiętam, że zrobiłem tam nawet prosty odtwarzacz muzyki, a że to były czasy kiedy ilość pamięci operacyjnej była bardzo ograniczona (256mb ram, athlon 1.6gHz z jednym rdzeniem, wooow) pozwalało mi wyłączyć winampa i słuchać muzyki bezpośrednio w kliencie. Swoją drogą bardzo fajnie mi to wyszło, pamiętam, że byłem dumny ze swojego UI, wykonałem cały brand w żółtej kolorystyce, a sam odtwarzacz posiadał playlistę oraz m.in. możliwość przełączania trybu między odtwarzaniem utworów po kolei lub losowo. Ponadto dopisałem sobie wtedy kilka "ficzerów" takich jak !whatplays przy którym automatycznie "odpowiadałem" wysyłając tytuł aktualnie odtwarzanego utworu.

Wracając do tematu, innym z kolei, dość dużym stopniu - eksperymentalnym - rozszerzeniem był prosty bot który zbierał wszystkie wiadomości wysłane przez userów, a następnie w dość losowej kolejności składał mniej lub bardziej logiczne zdania i wysyłał je w odpowiedzi reagując na konkretne polecenie zaczynające się od wykrzynika. I tak dla przykładu bot reagował na polecenie !bot i odpowiada na coś, dość często zabawnym ciągiem słów - bo ciężko to było nazwać zdaniem.

Nostalgłem i w ramach relaksu postanowiłem stworzyć sandbox dla ćwiczenia algorytmów z jakich mógłby korzystać bot. Jako bazę i battlefield do ćwiczeń wybrałem AjaxChat którego wybierałem nie raz, ciężko mi czasem było wybrać coś lepszego. Jest to czat asynchroniczny, napisany w php oraz js który jest bajecznie prosty w integrowaniu go z innymi projektami. Owszem, mógłbym wybrać do tego na przykład po prostu platformę Facebooka wraz z moją klasą PHP do obsługi wiadomości na fb, ale AjaxChat wydaje mi się lepszym "lokalnym" wyborem do eksperymentów. :)

AjaxChat pobieramy na przykład z GitHuba, następnie instalujemy go zmieniając nazwę pliku z config.php.example na config.php, który znajduje się w katalogu lib. Uzupełniamy w nim namiar na bazę danych, następnie w przeglądarce uruchamiamy install.php z katalogu głównego, który wypełnia nam bazę danych. Za cel obierzmy sobie plik CustomAJAXChat.php z folderu /lib/class, w nim stworzymy sobie funkcję:

function insertParsedMessage($text) {

	parent::insertParsedMessage($text);

	//Zakomentowana funkcja która posłuży nam do odpowiadania juzerowi
	//W pierwszym argumencie zostaje zwrócony kanał, w którym napisano wiadomość
	//$this->insertChatBotMessage($this->getChannel(), 'example answer', $ip=null, $mode=0);

}

Nadpisujemy oryginalną funkcję, wywołujemy następnie oryginał. Tutaj już zaczynamy zabawę. :)

W celu uzyskania jakiś zadowalających wyników niż bezmyślne zapisywanie słów i ich losowanie w ramach odpowiedzi zrobiłem research. Trafiłem na interesujce informacje o GPT-2, jednak to płatna opcja, więc odpada. :) Postawimy więc na napisanie czegoś własnego.

Na początek coś prostego. Sprawmy, aby bot pisał cokolwiek, ucząc się. W tym celu stworzymy 3 funkcje, do zapisu, odczytu losowania zdań.

private $_wordsFilename = './_wordsv1.dat';
private $_wordsDelimiter = ';;';
private $_words = [];

//funkcja pobierająca słowa
function _getWords()
{
	if (file_exists($this->_wordsFilename)) {
		$this->_words = explode($this->_wordsDelimiter,file_get_contents($this->_wordsFilename));
	}

	return $this->_words;
}

//funkcja zapisująca słowa
function _saveWords($text) {
	$words = $this->_getWords();
	$words = array_unique(
					array_merge(
						$words,
						explode(' ',trim($text))
					)
				);
	$this->_words = $words;
	file_put_contents($this->_wordsFilename,implode($this->_wordsDelimiter,$this->_words));
	return $this->_words;
}

//funkcja losująca słowa od 3 do 7
function _randomWords() {
	$len = rand(3,7);
	$str = [];
	$lenArr = count($this->_words);
	for ($i = 0; $i<$len; $i++) {
		$str[] = $this->_words[rand(0,$lenArr-1)];
	}
	return implode(' ',$str);
}

Oraz odkomentujmy funkcję odpowiadającą za odpowiedź bota z odpowiednimi argumentami:

$this->insertChatBotMessage($this->getChannel(), $this->_randomWords(), $ip=null, $mode=0);

Efekty? :)

 

Dobra, bot gada bez ładu i składu. Spróbujmy wymyślić coś poważniejszego. Otóż, jakby logować każde wprowadzone zdanie i każdemu ze słów w zależności od pozycji przypisać punktację od 1 do 10, a następnie na podstawie tego pobierać kolejne słowa które mają przybliżoną wartość dla kolejnych pozycji.

Założenia: zapis i odczyt równie szybki co w pierwotnej wersji. Należy sobie wyobrazić, co by było gdyby baza słów rozrosła się do dużych rozmiarów. Chciałbym wtedy, by dopisywanie kolejnych trwało szybko, więc zostaniemy przy dopisywaniu zawartości. Podzielimy więc bazę słów na pliki odpowiadające pozycji (score). W związku z tym zachodzą pewne zmiany. Modyfikujemy nieco zmienne prywatne:

private $_wordsFilename = './_wordsv2_s{score}.txt';
private $_wordsDelimiter = ';;';
private $_words = [[],[],[],[],[],[],[],[],[],[]];

Wg. powyższego zmienią się istniejące metody jak i dojdzie jedna pomocnicza - _toScore($pos,$len) która będzie obliczać score słowa na podstawie pozycji oraz długości zdania.

function _toScore($pos,$len) {
	return abs(round(((($pos*100) / $len) / 10) - 1));
}

Metoda _getWords() pobierająca słowa:

function _getWords($score)
{
	if (file_exists(str_replace('{score}',$score,$this->_wordsFilename))) {
		$this->_words[$score] = explode($this->_wordsDelimiter,file_get_contents(str_replace('{score}',$score,$this->_wordsFilename)));
	}
	return $this->_words[$score];
}

Metoda _saveWords() zapisująca słowa:

function _saveWords($text,$score) {
	$words = $this->_getWords($score);

	$includeDelimiter = (count($words) > 0) ? true : false;

	$diff = array_diff(
						explode(' ',$text),
						$words
					);

	$diff = array_filter($diff);

	$this->_words[$score] = array_filter(array_merge($words,$diff));

	$fp = fopen(str_replace('{score}',$score,$this->_wordsFilename), 'a');
	fwrite($fp, (($includeDelimiter) ? $this->_wordsDelimiter : '') . implode($this->_wordsDelimiter,$diff));
	fclose($fp);

	return $this->_words[$score];
}

 

Teraz bezpośrednio odbierającą wiadomości metodą jest metoda _parseWords() która dzieli ciąg na elementy tablicy wg. kropki, przecinka, znaku zapytania oraz wykrzyknika przy pomocy preg_split().

$sentences = preg_split('/(\.|,|\?|\!)/', $text);

Następnie iterujemy po tej tablicy, przycinamy oraz zmniejszamy znaki, jeśli wartość jest pusta (a zdarza się) to przechodzimy do kolejnego elementu. Usuwamy również znaki inne niż litery, a następnie znów dzielimy wg. spacji.

$s = strtolower(trim($s));

if (empty($s)) {
	continue;
}

$s = preg_replace('/[^\s\p{L}]+$/u','',$s);

$s = explode(' ',$s);

$len = count($s);

Iterujemy słowa trimując, puste pomijamy i zapisujemy wg. score'a. Cała metoda prezentuje się następująco:

function _parseWords($text) {

	$sentences = preg_split('/(\.|,|\?|\!)/', $text);

	foreach ($sentences as $s) {

		$s = strtolower(trim($s));

		if (empty($s)) {
			continue;
		}

		$s = preg_replace('/[^\s\p{L}]+$/u','',$s);

		$s = explode(' ',$s);

		$len = count($s);

		foreach ($s as $i => $v) {

			$v = trim($v);

			if (empty($v)) {
				continue;
			}

			$this->_saveWords($v,$this->_toScore($i+1,$len));

		}

	}

}

Losowanie słów analogicznie, wg. pozycji tj. score'a:

function _randomWords() {
	$len = rand(3,7);
	$str = [];
	for ($i = 0; $i<$len; $i++) {
		$score = $this->_toScore($i+1,$len);
		$this->_words[$score] = $this->_getWords($score);
		$lenArr = count($this->_words[$score]);
		$str[] = $this->_words[$score][rand(0,$lenArr-1)];
	}
	return ucfirst(mb_strtolower(implode(' ',$str))) . (rand(0,5) === 0 ? '?' : '');
}

Ponadto dla urealnienia dialogu (o ile można to już tak nazywać) dodałem 50% szans na odpowiedź bota oraz odpowiadanie na każde pytanie (jeśli na końcu wiadomości wystąpi znak zapytania).

function insertParsedMessage($text) {

	parent::insertParsedMessage($text);

	$this->_parseWords($text);

	if (rand(1,2) === 1 || substr($text, -1) == '?') {
		$this->insertChatBotMessage($this->getChannel(), $this->_randomWords(), $ip=null, $mode=0);
	}

}

 

Bot zaczął gadać składniej, już mniej przypomina to losowy zbiór słów, jednak nadal razi w oczy brak poprawnie gramatycznych zdań:

   

  

Słowem podsumowania, po odpowiednich modyfikacjach można tu podpiąć dowolny model i przeprowadzać na nim testy, enyoy!