moss-n-puddles/typeclasses/chatbots.py
2026-04-08 20:54:27 -07:00

493 lines
18 KiB
Python
Executable file

#!/usr/bin/env python
import anthropic
import json
import requests
import sys
from os import listdir, path
# from os.path import join, isfile
from pathlib import Path
from random import choice
from re import match, search, split, sub, IGNORECASE
from time import time
from evennia.utils import logger, delay
from evennia.utils.search import search_object
from typeclasses.scripts import Script
from typeclasses.puppets import Puppet
personality_dir = "personalities"
def fix_paragraph(paragraph):
"""
Because the number of tokens is small, a response may end mid-sentence.
Seems like displaying everything but the last fragment is sufficient.
"""
# Split the paragraph into sentences
sentences = split(r'(?<=[.!?])\s+', paragraph)
# Remove the last sentence if it doesn't end with punctuation
if not search(r"[.!?]\"?$", sentences[-1]):
sentences.pop()
return ' '.join(sentences)
class ChatBot(Puppet):
"""
py me.search("squirrel").backstory("squirrel")
"""
short_history = []
def pop_recent_events(self, speech):
if self.short_history:
# If we have already recorded the current speech we are
# responding to, we can remove it with a pop:
if match(rf".*{speech}.*", self.short_history[-1]):
self.short_history.pop()
history = '\n\n'.join(self.short_history)
self.short_history = []
return history
def backstory(self, personality=None):
"""
Read a file that includes a character's name and knowledge.
"""
if not personality:
files = listdir(personality_dir)
personalities = [f for f in files
if path.isfile(path.join(personality_dir, f))]
personality_file = choice(personalities)
else:
personality_file = personality + ".md"
filename = Path(path.join(personality_dir, personality_file))
if filename.exists():
personality = filename.stem
else:
logger.error(f"Chatbot Identity, {personality}, doesn't exist: {filename}")
with open(filename, "r") as ids:
details = ids.read()
# Create some paragraphs, and take the second:
content = details.split('\n\n')[1]
lines = content.splitlines()
desc = []
# Find name and description
for line in lines:
m = match(r"([A-z]+): +(.*)", line)
if m:
key = m.group(1).lower()
value = m.group(2).strip()
if key == "name":
self.aliases.remove()
self.aliases.add(value)
elif key == "description":
self.sdesc.add(value)
elif key == "gender":
self.db.gender = value
elif key == "pose":
self.db.pose = value
else:
desc.append(line.strip())
self.db.personality = personality
self.db.desc = ' '.join(desc)
self.db.personality_file = filename
return details
def setting_and_backstory(self, speaker=None):
logger.info(f"Reading {self.db.personality_file}")
system_prompt = Path(self.db.personality_file).read_text()
system_prompt += "\n\n"
system_prompt += "You are currently in " + self.location.key + ". "
if self.location.key == "Cozy House":
system_prompt += "This is the dwelling of the gnome, Dabbler."
if self.location.key == "Homey Hut":
system_prompt += "This is the dwelling of the witch, Trampoli."
system_prompt += "Described as " + self.location.desc
if speaker:
system_prompt += "\n\n"
system_prompt += "You are talking to a "
system_prompt += speaker.db.gender + " " + speaker.sdesc.get() + ". "
system_prompt += "Described as " + speaker.db.desc
# logger.info(f"Prompt: {system_prompt}")
return system_prompt
def history_file(self, speaker):
name = f"{speaker}".replace(" ", "-")
combo_name = f".{self.db.personality}-{name}.json".lower()
filename = path.join(personality_dir, combo_name)
logger.info(f"Chatbot history_file: {filename}")
return Path(filename)
def history(self, speaker):
history_file = self.history_file(speaker)
return json.loads(history_file.read_text()) if history_file.exists() else []
def update_history(self, speaker, messages, reply):
history_file = self.history_file(speaker)
messages.append({"role": "assistant", "content": reply})
history_file.write_text(json.dumps(messages, indent=2))
def _think(self, system_prompt, messages):
logger.info("Calling out to Anthropic...")
# Get reply
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-haiku-4-5",
max_tokens=240,
system=system_prompt,
messages=messages,
)
content = response.content[0].text
# logger.info(f"{content}")
return content
def think(self, speaker, speech):
"""
Ask Claude to think of a reply to speech from speaker.
Uses the 'system_prompt' from a personality file,
and 'messages' from the JSON history function,
appended with all 'events' recorded since last time.
"""
system_prompt = self.setting_and_backstory(speaker)
messages = self.history(speaker)
recent_events = self.pop_recent_events(speech)
if recent_events:
speech = f"{recent_events}\n\n{speaker.key}: {speech}"
messages.append({"role": "user", "content": speech})
reply = self._think(system_prompt, messages)
# Write reply
self.update_history(speaker, messages, reply)
# logger.info(f"{reply}")
return reply
def process_thoughts(self, response):
paragraphs = response.split('\n\n')
# logger.info(f"My reply will be: {paragraphs}")
for idx, paragraph in enumerate(paragraphs):
m = match(r"^ *\| *(.*)", paragraph)
if m:
action = m.group(1)
logger.info(f"Doing: '{action}'")
delay(6 * idx, self.execute_cmd, action)
else:
logger.info(f"Saying: '{paragraph}'")
delay(6 * idx,
self.location.msg_contents,
fix_paragraph(paragraph))
def other_say(self, speaker, speech):
logger.info(f"Chatbot hears: '{speech}' from {speaker}.")
logger.info(f"Characters: {self.characters_here()}")
if len(self.characters_here()) == 1:
self.other_sayto(speaker, speech)
def other_sayto(self, speaker, speech):
logger.info(f"Direct Chatbot hears: '{speech}' from {speaker}.")
if speech:
logger.info("Starting to think of a reply")
reply = self.think(speaker, speech)
self.process_thoughts(reply)
def at_msg_receive(self, text=None, from_obj=None, **kwargs):
super().at_msg_receive(text, from_obj=from_obj, **kwargs)
logger.info(f"at_msg_receive: {text} :: {self.key}")
if from_obj != self:
msg = text if isinstance(text, str) else text[0]
# Strip out the colored formatting (we will only strip the
# simple stuff):
msg = sub(r'\|[a-zA-Z]', '', msg) if msg else msg
if from_obj:
if hasattr(from_obj, 'sdesc') and from_obj.sdesc.get():
name = from_obj.sdesc.get()
else:
name = from_obj.key
self.short_history.append(f"{name}: {msg}")
else:
self.short_history.append(msg)
# We don't want to store _all_ the events:
self.short_history = self.short_history[-10:]
return True
class Bartender(ChatBot):
"""
Like any other Chatbot, but this one hears and responds to
more things.
"""
def backstory(self, personality):
personality_file = personality + ".md"
filename = Path(path.join(personality_dir, personality_file))
if filename.exists():
self.db.personality = filename.stem
self.db.personality_file = filename
def other_say(self, speaker, speech):
logger.info(f"Bartender hears: '{speech}' from {speaker}.")
if len(self.characters_here()) == 3 or \
match(r".*\b(bartend|barkeep|elendil).*", speech, IGNORECASE):
self.other_sayto(speaker, speech)
class Witch(ChatBot):
"""
@update Trampoli = typeclasses.chatbots.Witch
@set/delete Trampoli/arrive
"""
def visit(self):
self.execute_cmd("pose reset")
hut = self.location
delay(1, hut.msg_contents,
"The old lady says, \"I must be off now, dearie, to visit an old friend.\"")
delay(5, hut.msg_contents,
"She grabs and old broom, and flies out the door!")
dest = self.search("mp04", global_search=True)
delay(6, self.move_to, dest, quiet=True)
delay(7, dest.msg_contents,
"An old lady flies in on a broom.")
delay(10, dest.msg_contents,
"The old lady says, \"Hello Dearie, I'm just here to visit an old friend.\"")
knocker = self.search("knocker", global_search=True, location=dest)
delay(14, knocker.do_knock, self)
delay(20, dest.msg_contents,
"\"I think I heard a 'come in',\" says the old lady. \"You heard it too, right?\"")
home = self.search("mp03", global_search=True)
delay(24, self.move_to, home)
delay(30, home.msg_contents,
"\"Now this looks cheery,\" says the old lady, as she places her broom by the door. \"I do believe we are in for a spell of rain.\"")
def leave(self):
self.location.msg_contents("The old lady wraps her shawl tightly around her, and grabs her broom. \"I must be getting home now, dear,\" she says.")
dest = self.search("mp04", global_search=True)
delay(2, self.move_to, dest)
delay(4, dest.msg_contents,
"The old lady hops on her broomstick and flies away through the trees.\"")
hut = self.search("mp09", global_search=True)
delay(5, self.move_to, hut, quiet=True)
delay(6, hut.msg_contents,
"And old lady flies through the door on her broomstick! \"Hello dearies!\" she exclaims to everyone and everything in her homey hut. \"I have returned from visiting my friend. What have I missed while I was away?\"")
sleep_pose = self.attributes.get("pose_sleep")
if sleep_pose:
self.execute_cmd(f"pose {sleep_pose}")
class Traveler(ChatBot):
"""
Needs to walk from room to room, and greets characters.
"""
traveling_path = {}
def at_msg_receive(self, text=None, from_obj=None, **kwargs):
"""
Reset the timer whenever we get any event.
This might be too much.
"""
super().at_msg_receive(text, from_obj=from_obj, **kwargs)
self.db.last_event_time = time()
def other_arrive(self, character):
"""
Greet a character when it arrives.
"""
self.greet(character)
def at_post_move(self, past_location, move_type="move", **kwargs):
super().at_post_move(past_location, move_type)
chars = [c for c in self.characters_here() if c.key != 'tree']
if len(chars) == 1:
self.greet(chars[0])
else:
self.greet()
def greet(self, character=None):
delay(2, self.do_cmd, "emote waves.")
def goodbye(self, character=None):
delay(2, self.do_cmd, "emote waves good-bye.")
def change_direction(self):
"""
Hard coded directional change, east <-> west
Note: Change with the command:
@set npc/traveling_direction = "come"
"""
self.at_say("Well, I must be going.")
if self.db.traveling_direction == "east":
self.db.traveling_direction = "west"
else:
self.db.traveling_direction = "east"
logger.info(f"{self.db.traveling_direction}")
def come(self):
self.db.traveling_direction == "come"
class Dragon(Traveler):
"""
Travels east-to-west along the path, and has a drink at the
Wyldwood Tavern.
"""
# self.db.direction = "east" # or west or whatever
traveling_path = {
"the Deep Forest": {
"east": "Grotto",
"come": "Grotto"
},
"Grotto": {
"east": "Grove of the Matriarchs",
"west": "the Deep Forest",
"come": "door"
},
"Grove of the Matriarchs": {
"east": "Frog Meadow",
"west": "Grotto",
"come": "Grotto"
},
"Frog Meadow": {
"east": "Glittering Glade",
"west": "Grove of the Matriarchs",
"come": "Grove of the Matriarchs"
},
"Glittering Glade": {
"east": "Wyldwood Bar",
"west": "Frog Meadow",
"come": "Frog Meadow"
},
"Wyldwood Bar": {
"west": "Glittering Glade",
"come": "Glittering Glade"
}
}
def at_pre_move(self, destination, move_type="move", **kwargs):
if self.location.key == "Wyldwood Bar":
self.location.msg_contents("The little dragon tips his wide-brimmed and says, \"A pleasure, but I must be off.\"")
return True
def at_post_move(self, past_location, move_type="move", **kwargs):
# super().at_post_move(past_location, move_type)
if self.location.key == "Wyldwood Bar":
request = choice([
"a Moonlit Mirage",
"Puck's Revenge",
"a Glimmering Gossamer",
"a Whimsical Willow",
"a Charmed Chalice",
"an Enchanted Elixir",
"a Sylvan Serenade",
"a Brambleberry Bliss",
"a Twilight Tonic",
])
self.process_thoughts(f"""The little dragon flutters over to the bar, studying the menu. "What am I in the mood for now?" he muses to himself, twiddling a tendril like a mustache with his little claw.
"Elendil, dear," he says, "Would you make me {request}?" The little dragon then waves to the band on their mushroom stage.""")
bartender = self.search("bartender")
bartender.other_sayto(self,
f"\"Sir Roblees, the fairy dragon asks, \"Would you make me {request}?\"")
# from typeclasses.drinkables import Cocktail
# Cocktail.make(self, bartender, request)
def greet(self, character=None):
logger.info(f"Dragon: greet {character}")
if character:
name = character.get_name()
cmd = choice([
f"emote \"Hey, {name},\" /me says.",
f"emote \"Hey there, {name},\" /me says.",
f"emote \"Hello, {name},\" /me says. \"How are you?\"",
f"emote waves to {name}."
])
else:
cmd = choice([
"say Look at all these luscious peoples.",
"emote waves to everyone.",
"emote waves to everybody."
])
delay(5, self.do_cmd, cmd)
def goodbye(self, new_room=None):
self.do_cmd("drop drink")
system_prompt = self.setting_and_backstory()
messages = [{"role": "user", "content": "Say goodbye."}]
reply = self._think(system_prompt, messages)
self.process_thoughts(reply)
class TravelingNPC(Script):
"""
Script to move NPCs along a set path through "rooms".
Start the script by running the following:
@script npc = typeclasses.scripts.Muttering
"""
def at_script_creation(self):
self.key = "Traveling"
self.desc = "NPCs that Move"
self.interval = self.obj.db.traveling_interval or 120 # seconds
self.start_delay = False
self.persistent = True
self.reload()
def reload(self):
self.obj.db.last_event_time = time()
def at_repeat(self, **kwargs):
"""
Do we move or stay for another iteration?
"""
# What can keep a traveling NPC from traveling?
# What if receiving ANY message resets a timer?
elapsed_time = time() - self.obj.db.last_event_time
logger.info(f"TravelingNPC: {elapsed_time} > {3 * 60}")
# Time needs to be a little longer than the repeat interval.
if elapsed_time >= 20 * 60:
self.goodbye()
self.obj.change_direction()
elif elapsed_time >= 3 * 60:
direction_label = self.obj.db.traveling_direction
location_details = self.obj.traveling_path.get(self.obj.location.key)
if location_details:
logger.info(f"TravelingNPC: {direction_label} :: {location_details}")
room_name = location_details.get(direction_label)
if room_name:
logger.info(f"TravelingNPC: to -> {room_name}")
new_room = self.obj.search(room_name, global_search=True)
if new_room:
logger.info(f"TravelingNPC: to -> {new_room}")
# Say See ya if it had engaged...
self.obj.goodbye(new_room)
delay(5, self.obj.move_to, new_room, move_type="teleport")
# This process will reset the timer