Script-Time: Signal-Chat-Bot Teil 2

Stabiler Signal-Bot mit Auswahl der Gesprächspartner und variabler Aufgabe

Script-Time: Signal-Chat-Bot Teil 2
Photo by Mohammad Rahmani / Unsplash

Wenn man häufig von Personen kontaktiert wird, die glauben, man könne alle ihre Probleme lösen, und sie auch nach dem Hinweis, dass man im Urlaub ist, nicht damit aufhören, kann dies den Erholungseffekt erheblich beeinträchtigen.

Über meinen ersten Versuch, damit umzugehen, habe ich bereits berichtet:

KI für mehr Ruhe und Freizeit
Einfachere Anfragen an eine lokale KI abgeben ist leichter als gedacht.

Das Problem im ersten Ansatz war jedoch, dass die Chats kein Gedächtnis hatten. Somit hatte jede erzeugte Antwort auch keinen Bezug auf das eigentliche Gespräch. Das muss nun komplett überdacht werden.

Ein neuer Ansatz

Nachrichten abholen

Der Abruf neuer Signal-Nachrichten erfolgt, wie bisher, mittels folgendem Befehl:

signal-cli -u $MY_NUMBER -o json receive

Das JSON-Objekt speichere ich in einer temporären Datei /ramdisk/signal.txt und zerlege es mittels jq in seine einzelnen Chats (envelopes). Dabei interessieren mich nur Chats mit Textnachrichten. Lesebestätigungen und andere Meta-Informationen sollen nicht berücksichtigt werden:

cat /ramdisk/signal.txt | 
jq -c '.envelope'

Zerlegen in die wichtigen Informationen

Aus diesen Chats extrahiere ich mir den Absender, den Empfänger und die Nachricht:

SOURCE=$(echo "$message" | jq -r '.source')
DESTINATION=$(echo "$message" | jq -r '.syncMessage.sentMessage.destination // .dataMessage.destination // empty')
MESSAGE=$(echo "$message" | jq -r '.syncMessage.sentMessage.message // .dataMessage.message // "null"')

Da die Nachrichten ja immer an mich gerichtet sind, oder eben von mir geschrieben wurden, ist entweder $SOURCE oder $DESTINATION identisch mit meiner Telefonnummer. Daher findet das Gespräch offensichtlich mit der Telefonnummer statt, die nicht meine eigene ist 😉.

Ein Gedächtnis erzeugen

Ich hänge die Nachricht, also $MESSAGE, an eine Textdatei an, die den Chatverlauf mit dem Gesprächspartner enthält:

if [ "$SOURCE" == "$MY_NUMBER" ]; then
  echo "Ich: $MESSAGE" >> "${DESTINATION}.txt"
else
  echo "Mein Freund: $MESSAGE" >> "${SOURCE}.txt"
fi

Das führt zu folgenden Dateien:

Mit Inhalten, wie diesem:

Ich:  Gut, mal schauen, was es mit Gitlab auf sich hat . Hoffentlich keine zu großen Überraschungen! 樂
Mein Freund: Hast du schon einmal mit git gearbeitet?
Ich:  Natürlich habe ich schon mit git gearbeitet. Musste das für einen Kurs auch tun. 
Mein Freund: Ist das schwer zu lernen?
Ich:  Nein, git ist nicht besonders schwer zu lernen. Es erfordert etwas Zeit und Übung, aber es ist sehr nützlich für die Versionskontrolle von Code. Ich habe auch einen Kurs damit gemacht und fand es gut. 
Mein Freund: Was für einen Kurs hast du da gemacht?
Ich: ...

"Ich:" ist also immer das, was ich bisher gesagt habe, oder was die KI für mich in den Chat geschrieben hat.

Personen auswählen, mit denen die KI chatten soll

Um nun herauszufinden, ob die KI eine Antwort generieren soll, muss im Array ${USERS[@]} geschaut werden, ob die $SOURCE mit einer dieser Nummern übereinstimmt:

for user in "${USERS[@]}"; do
  if [ "$SOURCE" == "$user" ]; then
  echo "Ich denke mir eine Antwort aus..."
  ...

Sollte die KI eine Antwort erstellen müssen, müssen wir ihr erklären, was wir von ihr wollen.

Die KI erstellt eine Antwort

Sie soll natürlich über den alten Gesprächsverlauf ($DIALOG) bescheid wissen, damit bei kurzen Nachfragen, wie "Und wie kann man das lösen?", der Kontext bekannt ist.

Die eigentliche Antwort soll aber immer nur für die letzte Nachricht ($MESSAGE) erstellt werden.

Damit die KI nicht versucht, das Gespräch am laufen zu halten, muss man ihr sagen, dass sie kein Assistent ist, sondern sich so verhalten soll, wie ich es im bisherigen Chatverlauf getan habe. Auf diese Weise wird unterschieden, ob das Gespräch eher förmlich war (weil vielleicht mit einem Geschäftspartner), oder eher lockerer (Kuhl, Alder!).

Auf keinen Fall darf die KI begründen, warum sie die Antwort so formuliert hat, was letztlich zu folgender Anweisung führt:

Das folgende Gespräch hat gerade stattgefunden:
$DIALOG

Antworte meinem Freund auf deutsch und so kurz wie möglich auf diese Nachricht:
$MESSAGE

Berücksichtige dabei, dass du kein Assistent bist, und das Gespräch nicht aufrecht erhalten musst.
Achte genau auf die Art und Weise, wie ich bisher in diesem Dialog gesprochen habe.
Verwende vergleichbaren Humor, ähnliche Wortwahl und Ausdrucksweise.

Smileys dürfen nur als Zeichen eingefügt werden. Die Verwendung von Smileybeschreibungen in Klammern ist verboten.

Gib nur die rohe Antwort aus. Ausser der reinen Antwort darf keine weitere Ausgabe oder Erklärung erfolgen.

Nachdem die unbequemen Zeichen aus dieser Anfrage umgewandelt wurden, wird sie schließlich an die KI übergeben:

# Ersetzen von Zeilenumbrüchen durch \n und Anführungszeichen durch \"
FRAGE_ESCAPED=$( echo $FRAGE | sed ':a;N;$!ba;s/\n/\\n/g;s/"/\\"/g' )

# Verwendung der Variablen im curl-Befehl
ANTWORT=$(curl -s http://ai-server:11434/api/generate --data-raw "{
\"model\": \"${AIMODEL}\",
\"prompt\": \"${FRAGE_ESCAPED}\",
\"stream\": false}" | jq -r .response)

Und wieder zurück an den Absender...

Die $ANTWORT kann dann an die $SOURCE zurückgeschickt werden:

signal-cli -u $MY_NUMBER send -m "$ANTWORT" "$SOURCE"

Dies war mein Test-Chat mit diesem Script. Den blau unterlegten Text hat die KI auf meine Nachrichten geantwortet:

Wie man sieht, erzeugt die KI relativ genau nachempfundene Redeweisen mit sinnvollen Inhalten.

Das fertige Script

Und so sieht das ganze Script dann aus:

!/bin/bash

# Message-Folder
cd /var/log/signal

TOKEN="254562456:AAE24562456662466534624562456CiDvnU"
CHAT_ID="345724577"

AIMODEL="mixtral:instruct"
# AIMODEL="phi3:mini"

# Meine Telefonnummer
MY_NUMBER="+4917ABCDEFG02"

# Array mit Telefonnummern der Partner, die von der KI beantwortet werden sollen.
USERS=(
    "+4915277188298"
    "+4917159876153"
    "+4916168446581"
    # Weitere Benutzer hinzufügen bei Bedarf
)

# Empfange die Nachrichten mit signal-cli und prüfe, ob das noch funktioniert.
/usr/local/bin/signal-cli -u $MY_NUMBER -o json receive > /ramdisk/signal.txt || curl -s -X POST "https://api.telegram.org/bot${TOKEN}/sendMessage" -d "chat_id=${CHAT_ID}&text=Hier gibts ein Problem mit der signal-cli auf $(hostname)"

cat /ramdisk/signal.txt | 
jq -c '.envelope' |

while read -r message; do
    # Extrahiere die User-ID (source) aus der Nachricht
    SOURCE=$(echo "$message" | jq -r '.source')
    
    # Extrahiere die Zielnummer (destination) aus der Nachricht
    DESTINATION=$(echo "$message" | jq -r '.syncMessage.sentMessage.destination // .dataMessage.destination // empty')
    
    # Extrahiere die eigentliche Nachricht
    MESSAGE=$(echo "$message" | jq -r '.syncMessage.sentMessage.message // .dataMessage.message // "null"')
    # Prüfe, ob MESSAGE nicht "null" ist
    if [ "$MESSAGE" != "null" ] ; then

        echo "$SOURCE: $MESSAGE"

        # Erstelle den Dateinamen basierend auf der User-ID (source)
        if [ "$SOURCE" == "$MY_NUMBER" ]; then
            echo "Ich: $MESSAGE" >> "${DESTINATION}.txt"
        else
            echo "Mein Freund: $MESSAGE" >> "${SOURCE}.txt"
        fi

        # Überprüfe, ob die Nachricht von einem der Benutzer im USERS-Array kommt
        for user in "${USERS[@]}"; do
            if [ "$SOURCE" == "$user" ]; then

                echo "Ich denke mir eine Antwort aus..."
                echo
                
                # Füge eine Chat-History hinzu, um die Frage zu formulieren
                DIALOG="$(cat ${SOURCE}.txt)"
FRAGE=$(cat <<EOF
Das folgende Gespräch hat gerade stattgefunden:
$DIALOG

Antworte meinem Freund auf deutsch und so kurz wie möglich auf diese Nachricht:
$MESSAGE

Berücksichtige dabei, dass du kein Assistent bist, und das Gespräch nicht aufrecht erhalten musst.
Achte genau auf die Art und Weise, wie ich bisher in diesem Dialog gesprochen habe.
Verwende vergleichbaren Humor, ähnliche Wortwahl und Ausdrucksweise.

Smileys dürfen nur als Zeichen eingefügt werden. Die Verwendung von Smileybeschreibungen in Klammern ist verboten.
Gib nur die rohe Antwort aus. Ausser der reinen Antwort darf keine weitere Ausgabe erfolgen.
EOF
)
                # FRAGE="$(cat ${SOURCE}.txt) ${GENERATERESPONSE}"

                # Ersetzen von Zeilenumbrüchen durch \n und doppelten Anführungszeichen durch \"
                FRAGE_ESCAPED=$( echo $FRAGE | sed ':a;N;$!ba;s/\n/\\n/g' | sed 's/"/\\"/g')

                # Verwendung der Variablen im curl-Befehl
                ANTWORT=$(curl -s http://ai-server:11434/api/generate --data-raw "{
                \"model\": \"${AIMODEL}\",
                \"prompt\": \"${FRAGE_ESCAPED}\",
                \"stream\": false}" | jq -r .response)

                # Mit der generierten Antwort dem User antworten.           
                /usr/local/bin/signal-cli -u $MY_NUMBER send -m "${ANTWORT//\"/}" "$SOURCE" > /dev/null 2>&1

                # Hänge die Antwort an den Dialog an
                echo "Ich: ${ANTWORT//\"/}" >> ${SOURCE}.txt
            fi
        done

    fi
done
rm /ramdisk/signal.txt 

Ich rufe es per cron alle sechs Minuten auf. Der Test-Server, der aktuell die Antworten generiert, hat keine Grafikkarte. So kann es also eine Weile dauern, bis Antworten generiert sind:

Fazit

Dieses Script ist inzwischen recht stabil und kann nun auch auf weitere Ruhestörer ausgeweitet werden.

Aktuell probiere ich unterschiedliche Modelle für die Erzeugung der Antworten aus. Dabei ist mir aufgefallen, dass mixtral:instruct, wie auch weitere instruct Modelle, sehr an Fakten hängen und teils die gleiche Antwort wiederholen. Llama3 Varianten erkennen scheinbar Wiederholungen der Fragen und reagieren manchmal fast gereizt mit: "Das habe ich doch eben gerade erklärt:......!"

Ich denke, ich werde mit KI noch viel Spaß haben...