Table of Contents

ChangeLog

9 April 2013

30 March 2013

  • Racter's responses are now delayed at 240 WPM.

29 March 2013

  • The script is now OpenSim compatible.

28 March 2013

  • Revised code.

Introduction

Racter is a cynical nonsense speaking chatterbot modeled as a tribute to the Amiga's classic Racter program but with a little more brain by taking concepts from Eliza chatterbots. Racter allows multiple brain-files (notecards in SecondLife) to be added in order to expand the palette of possible keywords and the replies that the bot will give. In order to not clutter up the main chat, Racter is programmed to only answer to sentences that include its name. However, Racter has a feature which allows it to talk freely with a certain configurable probability.

Setting Up Racter

Notecard
Racter_1
Racter_2

Example fisher-boy called Racter.

If everything is set-up correctly, the contents of the primitive should be the Racter script and a notecard named Racter_1.

Changing Racter's Name

In order to change the chatterbot's name from Racter to something else, you have to do the following:

Following the example, at the end, the prim's inventory would look like this:

Eliza
Eliza_1
Eliza_2

where Eliza is the LSL script below, and Eliza_1 and Eliza_2 are the notecards.

Speaking Freely on Main Chat

The bot will answer sentences on the main chat if:

For probabilities, as a factory setting, Racter is configured to answer all the sentences it sees on the main chat, regardless whether they contain the bot's name or not. This might prove to be quite spammy if there are a lot of people speaking on the main chat. In order to change that, you have to locate the following line:

integer SPEAK_FREELY = 40;

and change it to a lower percentage. This is roughly the probability with which the bot will answer a sentence it sees on the main chat. For example, if you want the bot to be silent, you can change this to:

integer SPEAK_FREELY = 0;

and the bot will only answer sentences if it sees its own name in the sentence.

Notecard Syntax

The most important part of Racter, are the notecards which constitute the bot's brains. The syntax of the lines in the notecards is the following:

<keyword>/<keyword>/<...>#<answer>/<answer>/<answer>/<...>#<weight>

where the keywords can be single words or phrases, the answers can be words or phrases and the weight is an integer.

The way that Racter works, is that it analyses a sentence and chooses a random answer from the pool with the biggest weight. This might seem complex, but it is easy to understand. Let us take an example. Suppose that you would say on the main chat:

Morgan LeFay: hello racter, how are you today?

and suppose that there is only one notecard containing:

hello#hi there!/how do you do?#1
how are you#all's fine and dandy/just fine#2

Racter will pick apart your sentence, it will see "hello" and it will see "how are you" since they are both in the notecard. Then it will select an answer based on the weight. In this case, it does see "hello" but it has a weight of "1" and it will also see "how are you" which has a bigger weight of "2". Because of that, it will answer with either "all's fine and dandy" or "just fine" randomly. Here is a transcript:

Morgan LeFay: hello racter, how are you today?
Racter: all's fine and dandy

Whenever you edit a notecard/brain file for Racter, you should consider that some words take precedence over others. In the example above, we could have added another line:

you#what about me?#5

but this would not be plausible since when somebody asks you a question or tells you something, you would seldom choose to craft your reply around the word "you". You would most likely comment something about "today", or since the question really is "how are you", you would most likely answer to that.

Racter supports any number of brain files (up the script memory limit). You can add additional notecards if you like, but make sure that they follow the name pattern. Again, if the bot is to be called Eliza, your notecards will be named Eliza_1, Eliza_2, Eliza_3 and so on.

Miss Responses

Racter is meant to be a chatbot by answering with a general statement or question in order to make it a likely response. However, indifferent of the amount of notecards you add, there might be some unforeseeable events when Racter cannot match any keyword. In this case, Racter is programmed to give you a miss response. This miss response is hardcoded in the code and you might want to change that as well. Again, it is wise to chose this carefully since this response will be given when absolutely nothing matches and if you want Racter to appear real, the miss response must be something vague enough.

The miss responses can be found in the code in the form of a list:

list MISS_RESPONSES = [ "yeah, i guess", "mhm, i concurr", "doesn't make much sense to me", "i see", "aha, ok", "i know, right", "are you talking to me?", "uff, perhaps", "i'm not sure", "i have a headache", "my head hurts", "come again?", "i hear you", "my thoughts are cloudy", "i'm bored", "perhaps", "i see what you're saying", "hmm", "well, ok", "i get it" ];

You can add more miss responses to this list, or take away any of them. Just follow the list example and change the code accordingly.

Limitations

The Engine

This script was tested and works on OpenSim version 0.7.4!

racter.lsl
///////////////////////////////////////////////////////////////////////////
//  Copyright (C) Wizardry and Steamworks 2013 - License: GNU GPLv3      //
//  Please see: http://www.gnu.org/licenses/gpl.html for legal details,  //
//  rights of fair usage, the disclaimer and warranty conditions.        //
///////////////////////////////////////////////////////////////////////////
 
//////////////////////////////////////////////////////////
//                   CONFIGURATION                      //
//////////////////////////////////////////////////////////
 
// How likely is it (from 0 to 100), that Racter will 
// answer even if it is not directly spoken to.
integer SPEAK_FREELY = 100;
// These are the responses that racter will answer with
// in case no keywords have been matched from the brain.
// They can be changed or altered in any way but should 
// be general enough to be a passable valid answer to 
// any statement or question that the avatar asks.
list MISS_RESPONSES = [ "yeah, i guess", "doesn't make much sense to me", "i see", "aha, ok", "i'm not sure", "come again?", "i hear you", "my thoughts are cloudy", "perhaps", "hmm" ];
 
///////////////////////////////////////////////////////////////////////////
//                              INTERNALS                                //
///////////////////////////////////////////////////////////////////////////
 
///////////////////////////////////////////////////////////////////////////
//    Copyright (C) 2013 Wizardry and Steamworks - License: GNU GPLv3    //
///////////////////////////////////////////////////////////////////////////
list wasDualQuicksort(list a, list b) {
    if(llGetListLength(a) <= 1) return a+b;
 
    integer pivot_a = llList2Integer(a, 0);
    a = llDeleteSubList(a, 0, 0);
    string pivot_b = llList2String(b, 0);
    b = llDeleteSubList(b, 0, 0);
 
    list less = [];
    list less_b = [];
    list more = [];
    list more_b = [];
 
    do {
        if(llList2Integer(a, 0) > pivot_a) {
            less += llList2List(a, 0, 0);
            less_b += llList2List(b, 0, 0);
            jump continue;
        }
        more += llList2List(a, 0, 0);
        more_b += llList2List(b, 0, 0);
@continue;
        a = llDeleteSubList(a, 0, 0);
        b = llDeleteSubList(b, 0, 0);
    } while(llGetListLength(a));
    return wasDualQuicksort(less, less_b) + [ pivot_a ] + [ pivot_b ] + wasDualQuicksort(more, more_b);
}
 
key eQuery = NULL_KEY;
list brain = [];
list brain_cards = [];
string current_card = "";
integer nLine = 0;
string sayLine = "";
 
default
{
    state_entry() {
        integer i = llGetInventoryNumber(INVENTORY_NOTECARD)-1;
        do {
            list tCard = llParseString2List(llGetInventoryName(INVENTORY_NOTECARD,i), ["_"], [""]);
            if(llGetListLength(tCard) != 2 || llList2String(tCard, 0) != llGetScriptName()) jump continue;
            brain_cards += llGetInventoryName(INVENTORY_NOTECARD, i);
@continue;
        } while(--i>-1);
        if(llGetListLength(brain_cards) != 0) jump found_notecards;
        llSay(DEBUG_CHANNEL, "No brain cards found.");
        return;
@found_notecards;
        llOwnerSay("Reading brain files... Please wait...");
        current_card = llList2String(brain_cards, 0);
        brain_cards = llDeleteSubList(brain_cards, 0, 0);
        eQuery = llGetNotecardLine(current_card, nLine);
    }
 
    dataserver(key id, string data) {
        if(id != eQuery) return;
        if(data == EOF) {
            // check if we are done with notecards.
            if(llGetListLength(brain_cards) != 0) {
                // ... if we are not, continue reading the next notecard
                current_card = llList2String(brain_cards, 0);
                brain_cards = llDeleteSubList(brain_cards, 0, 0);
                nLine = 0;
                eQuery = llGetNotecardLine(current_card, nLine);
                return;
            }
            // now sort the brain in decreasing order by weights.
            list keyData = [];
            list weights = [];
            do {
                list split = llParseString2List(llList2String(brain, 0), ["#"], [""]);
                brain = llDeleteSubList(brain, 0, 0);
                keyData += llList2String(split, 0) + "#" + llList2String(split, 1);
                weights += llList2String(split, 2);
            } while(llGetListLength(brain));
            list sorted = wasDualQuicksort(weights, keyData);
            keyData = llList2ListStrided(llDeleteSubList(sorted, 0, 0), 0, llGetListLength(sorted)-1, 2);
            weights = llList2ListStrided(sorted, 0, llGetListLength(sorted)-1, 2);       
            do {
                brain += llList2String(keyData, 0) + "#" + llList2String(weights, 0);
                keyData = llDeleteSubList(keyData, 0, 0);
                weights = llDeleteSubList(weights, 0, 0);
            } while(llGetListLength(keyData));
            // brain is organized now, switch to racter.
            llOwnerSay("Done reading brain files... Racter ready.");
            state racter;
        }
        if(data == "") jump next_line;
        list data_line = llParseString2List(data, ["#"], [""]);
        if(llList2String(data_line, 0) == "" || llList2String(data_line, 1) == "" || llList2String(data_line,2) == "") jump next_line;
        brain += data;
@next_line;
        eQuery = llGetNotecardLine(current_card, ++nLine);
    }
 
    changed(integer change) {
        if(change & CHANGED_INVENTORY) llResetScript();
    }
 
    on_rez(integer start_param) {
        llResetScript();
    }
 
}
 
state racter {
 
    state_entry() {
        llListen(0, "", "", "");
    }
 
    listen(integer chan, string name, key id, string mes) {
        // if it's an object talking, ignore it.
        if(llList2Key(llGetObjectDetails(id, [OBJECT_CREATOR]), 0) != NULL_KEY) return;
        if(llStringTrim(name, STRING_TRIM) == llGetScriptName()) return;
        // change the message to lowercase.
        mes = llToLower(mes);
        // compute the total score for the current keyword for all keyword lines.
        // we need this for the cumulative probability afterward.
        integer keywordScore = 0;
        integer i = llGetListLength(brain)-1;
        do {
            list data = llParseString2List(llList2String(brain, i), ["#"], []);
            list keywords = llParseString2List(llList2String(data, 0), ["/"], []);
            do {
                string keyword = llToLower(llList2String(keywords, 0));
                keywords = llDeleteSubList(keywords, 0, 0);
                if(llSubStringIndex(mes, keyword) == -1) jump continue;
                keywordScore += llList2Integer(data, 2);
@continue;
 
            } while(llGetListLength(keywords));
        } while(--i>-1);
        // if the score is zero
        if(keywordScore != 0) jump matched;
        // ... and if the script name is in the message, answer with a miss response.
        if(llSubStringIndex(mes, llToLower(llGetScriptName())) != -1) jump miss;
        // ... and if we are not allowed to speak freely, return.
        if(SPEAK_FREELY == 0) return;
        // ... and if we may speak freely, and if the probability treshold is not high enough, just bail-out.
        if((integer)llFrand((integer)(100/SPEAK_FREELY))!=(integer)llFrand((integer)(100/SPEAK_FREELY))) return;
@miss;
        if(llGetListLength(MISS_RESPONSES) == 0) return;
        sayLine = llList2String(MISS_RESPONSES, (integer)llFrand(llGetListLength(MISS_RESPONSES)));
        // and bail-out.
        state speak;
@matched;
        // If we matched a keyword then, based on the cumulative probabilities
        // of the possible responses, select a response and say it in local chat.
        float random = llFrand(keywordScore);
        float cumulative = 0;
        i = llGetListLength(brain)-1;
        do {
            list data = llParseString2List(llList2String(brain, i), ["#"], []);
            list keywords = llParseString2List(llList2String(data, 0), ["/"], []);
            do {
                string keyword = llToLower(llList2String(keywords, 0));
                keywords = llDeleteSubList(keywords, 0, 0);
                if(llSubStringIndex(mes, keyword) == -1) jump skip;
                cumulative += llList2Integer(data, 2);
                if(cumulative >= random) {
                    if(llSubStringIndex(mes, llToLower(llGetScriptName())) != -1) jump respond;
                    if(SPEAK_FREELY == 0) return;
                    if((integer)llFrand((integer)(100/SPEAK_FREELY))!=(integer)llFrand((integer)(100/SPEAK_FREELY))) return;
@respond;
                    list sayOptions = llParseString2List(llList2String(data, 1), ["/"], []);
                    sayLine = llList2String(sayOptions, (integer)llFrand(llGetListLength(sayOptions)));
                    state speak;
                }
@skip;
            } while(llGetListLength(keywords));
        } while(--i>=0);
 
    }
 
    changed(integer change) {
        if(change & CHANGED_INVENTORY) llResetScript();
    }
 
    on_rez(integer start_param) {
        llResetScript();
    }
}
 
state speak {
    state_entry() {
        // 40 WPM = 240 characters / minute = 4 characters / second.
        llSetTimerEvent((float)llStringLength(sayLine)/4.0);
    }
    timer() {
        // now say it.
        llSay(0, sayLine);
        // and switch to racter.
        state racter;
    }
    on_rez(integer start_param) {
        llResetScript();
    }
}