Skip to content

Commit

Permalink
Merge pull request #2 from neph1/give_and_json
Browse files Browse the repository at this point in the history
LLM decision making (experimental)
  • Loading branch information
neph1 authored Aug 8, 2023
2 parents f312559 + 8b83643 commit f8e4950
Show file tree
Hide file tree
Showing 34 changed files with 1,168 additions and 121 deletions.
8 changes: 8 additions & 0 deletions stories/prancingllama/npcs/npcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from tale.base import Item, Living, ParseResult
from tale.errors import ParseError, ActionRefused
from tale.lang import capital
from tale.llm_ext import LivingNpc
from tale.player import Player
from tale.util import call_periodically, Context
Expand Down Expand Up @@ -48,6 +49,10 @@ def do_random_move(self, ctx: Context) -> None:
if direction:
self.move(direction.target, self, direction_names=direction.names)

@call_periodically(30, 60)
def do_pick_up_dishes(self, ctx: Context) -> None:
self.location.tell("%s wipes a table and picks up dishes." % capital(self.title), evoke=False)

class Patron(LivingNpc):

def __init__(self, name: str, gender: str, *,
Expand Down Expand Up @@ -118,4 +123,7 @@ def do_idle_action(self, ctx: Context) -> None:
urta = InnKeeper("Urta", "f", age=44, descr="A gruff, curvy woman with a long brown coat and bushy hair that reaches her waist. When not serving, she keeps polishing jugs with a dirty rag.", personality="She's the owner of The Prancing Llama, and of few words. But the words she speak are kind. She knows a little about all the patrons in her establishment.", short_descr="A curvy woman with long brown coat standing behind the bar.")
urta.aliases = {"bartender", "inn keeper", "curvy woman"}

brim = Maid("Brim", "f", age=22, descr="A timid girl with long dark blonde hair in a braid down her back. She carries trays and dishes back and forth.", personality="She's shy and rarely looks anyone in the eye. When she speaks, it is merely a whisper. She traveled up the mountain looking for work, and ended up serving at the Inn. She dreams of big adventures.", short_descr="A timid maid with a braid wearing a tunic and dirty apron.")
brim.aliases = {"maid", "timid girl", "serving girl"}


2 changes: 1 addition & 1 deletion stories/prancingllama/story.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class Story(StoryBase):
config.version = tale.__version__
config.supported_modes = {GameMode.IF, GameMode.MUD}
config.player_money = 10.5
config.playable_races = {"human", "elf", "dwarf", "hobbit"}
config.playable_races = {"human"}
config.money_type = MoneyType.FANTASY
config.server_tick_method = TickMethod.TIMER
config.server_tick_time = 0.5
Expand Down
4 changes: 2 additions & 2 deletions stories/prancingllama/zones/prancingllama.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@ def spawn_rat(self, ctx: Context) -> None:

bar.init_inventory([urta, norhardt])

hearth.init_inventory([count_karta])
hearth.init_inventory([count_karta, brim])

drink = Item("ale", "jug of ale", descr="Looks and smells like strong ale.")

urta.init_inventory([drink])

old_map = Item("map", "old map.", descr="It looks like a map, and a cave is marked on it.")
old_map = Item("map", "old map", descr="It looks like a map, and a cave is marked on it.")

norhardt.init_inventory([old_map])

23 changes: 13 additions & 10 deletions tale/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,8 @@ def tell(self, room_msg: str, exclude_living: 'Living'=None, specific_targets: S
that based on this message string. That will make it quite hard because you need to
parse the string again to figure out what happened... Use handle_verb / notify_action instead.
"""
#alt_prompt ="### Instruction: Location: [" + str(self.look(short=True)) + "]. Rewrite the following text in your own words using vivid language, use 'location' for context. Text:\n\n [{input_text}] \n\nEnd of text.\n\n### Response:\n"

targets = specific_targets or set()
assert isinstance(targets, (frozenset, set, list, tuple))
assert exclude_living is None or isinstance(exclude_living, Living)
Expand Down Expand Up @@ -669,7 +671,7 @@ def message_nearby_locations(self, message: str) -> None:
if exit.target in yelled_locations:
continue # skip double locations (possible because there can be multiple exits to the same location)
if exit.target is not self:
exit.target.tell(message, evoke=True, max_length=True)
exit.target.tell(message, evoke=False, max_length=True)
yelled_locations.add(exit.target)
for direction, return_exit in exit.target.exits.items():
if return_exit.target is self:
Expand All @@ -684,7 +686,7 @@ def message_nearby_locations(self, message: str) -> None:
direction = "below"
else:
continue # no direction description possible for this exit
exit.target.tell("The sound is coming from %s." % direction, evoke=True, max_length=True)
exit.target.tell("The sound is coming from %s." % direction, evoke=False, max_length=True)
break

def nearby(self, no_traps: bool=True) -> Iterable['Location']:
Expand Down Expand Up @@ -1034,7 +1036,7 @@ def wiz_clone(self, actor: 'Living', make_clone: bool=True) -> 'Living':
actor.tell("Cloned into: " + repr(duplicate) + " (spawned in current location)")
actor.tell_others("{Actor} summons %s..." % lang.a(duplicate.title))
actor.location.insert(duplicate, actor)
actor.location.tell("%s appears." % lang.capital(duplicate.title), evoke=True, max_length=True)
actor.location.tell("%s appears." % lang.capital(duplicate.title), evoke=False, max_length=True)
return duplicate

@util.authorized("wizard")
Expand Down Expand Up @@ -1274,7 +1276,7 @@ def display_direction(directions: Sequence[str]) -> str:
message = "%s leaves %s." % (lang.capital(self.title), direction_txt)
else:
message = "%s leaves." % lang.capital(self.title)
original_location.tell(message, exclude_living=self, evoke=True, max_length=True)
original_location.tell(message, exclude_living=self, evoke=False, max_length=True)
# queue event
if is_player:
pending_actions.send(lambda who=self, where=target: original_location.notify_player_left(who, where))
Expand All @@ -1283,7 +1285,7 @@ def display_direction(directions: Sequence[str]) -> str:
else:
target.insert(self, actor)
if not silent:
target.tell(f"{lang.capital(self.title)} arrives from {original_location}." , exclude_living=self, evoke=True, max_length=True)
target.tell(f"{lang.capital(self.title)} arrives from {original_location}." , exclude_living=self, evoke=False, max_length=True)
# queue event
if is_player:
pending_actions.send(lambda who=self, where=original_location: target.notify_player_arrived(who, where))
Expand Down Expand Up @@ -1340,6 +1342,7 @@ def start_attack(self, victim: 'Living') -> None:
victim_msg = "%s attacks you. %s" % (name, result)
attacker_msg = "You attack %s! %s" % (victim.title, result)
victim.tell(victim_msg, evoke=True, max_length=False)
# TODO: try to get from config file instead
combat_prompt = f'### Instruction: Rewrite the following combat between user {name} and {victim.title} and result into a vivid description in less than 300 words. Location: {self.location}, {self.location.short_description}. Write one to two paragraphs, ending in either death, or a stalemate. Combat Result: {attacker_msg} ### Response:\n\n'
victim.location.tell(room_msg, exclude_living=victim, specific_targets={self}, specific_target_msg=attacker_msg, evoke=True, max_length=False, alt_prompt=combat_prompt)
if dead:
Expand Down Expand Up @@ -1682,7 +1685,7 @@ def open(self, actor: Living, item: Item=None) -> None:
actor.tell_others("{Actor} opens the %s." % self.name, evoke=True, max_length=True)
if self.linked_door:
self.linked_door.opened = True
self.target.tell("The %s is opened from the other side." % self.linked_door.name, evoke=True, max_length=True)
self.target.tell("The %s is opened from the other side." % self.linked_door.name, evoke=False, max_length=True)

def close(self, actor: Living, item: Item=None) -> None:
"""Close the door with optional item. Notifies actor and room of this event."""
Expand All @@ -1693,7 +1696,7 @@ def close(self, actor: Living, item: Item=None) -> None:
actor.tell_others("{Actor} closes the %s." % self.name, evoke=True, max_length=True)
if self.linked_door:
self.linked_door.opened = False
self.target.tell("The %s is closed from the other side." % self.linked_door.name, evoke=True, max_length=True)
self.target.tell("The %s is closed from the other side." % self.linked_door.name, evoke=False, max_length=True)

def lock(self, actor: Living, item: Item=None) -> None:
"""Lock the door with the proper key (optional)."""
Expand All @@ -1713,11 +1716,11 @@ def lock(self, actor: Living, item: Item=None) -> None:
if not key:
raise ActionRefused("You don't seem to have the means to lock it.")
self.locked = True
actor.tell("Your %s fits, the %s is now locked." % (key.title, self.name), evoke=True, max_length=True)
actor.tell_others("{Actor} locks the %s with %s." % (self.name, lang.a(key.title)), evoke=True, max_length=True)
actor.tell("Your %s fits, the %s is now locked." % (key.title, self.name), evoke=False, max_length=True)
actor.tell_others("{Actor} locks the %s with %s." % (self.name, lang.a(key.title)), evoke=False, max_length=True)
if self.linked_door:
self.linked_door.locked = True
self.target.tell("The %s is locked from the other side." % self.linked_door.name, evoke=True, max_length=True)
self.target.tell("The %s is locked from the other side." % self.linked_door.name, evoke=False, max_length=True)

def unlock(self, actor: Living, item: Item=None) -> None:
"""Unlock the door with the proper key (optional)."""
Expand Down
12 changes: 12 additions & 0 deletions tale/cmds/normal.py
Original file line number Diff line number Diff line change
Expand Up @@ -1678,3 +1678,15 @@ def do_account(player: Player, parsed: base.ParseResult, ctx: util.Context) -> N
player.tell("created: %s (%d days ago)" % (account.created, days_ago.days), end=True)
player.tell("last login: %s" % account.logged_in, end=True)
player.tell("\n")

@cmd("load_character")
def do_load_character(player: Player, parsed: base.ParseResult, ctx: util.Context) -> None:
"""Load a character from file."""
if len(parsed.args) != 1:
raise ParseError("You need to specify the path to the character file")
try:
path = str(parsed.args[0])
except ValueError as x:
raise ActionRefused(str(x))

ctx.driver.load_character(player, path)
18 changes: 18 additions & 0 deletions tale/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
from .tio import DEFAULT_SCREEN_WIDTH
from .races import playable_races
from .errors import StoryCompleted
from tale.load_character import CharacterLoader, CharacterV2
from tale.llm_ext import LivingNpc


topic_pending_actions = pubsub.topic("driver-pending-actions")
Expand Down Expand Up @@ -777,6 +779,22 @@ def register_periodicals(self, obj: base.MudObject) -> None:
for func, period in util.get_periodicals(obj).items():
assert len(period) == 3
mud_context.driver.defer(period, func)

def load_character(self, player: player.Player, path: str):
character_loader = CharacterLoader()
char_data = character_loader.load_character(path)
character = CharacterV2().from_json(char_data)
npc = LivingNpc(name = character.name.lower(),
gender = character.gender,
title = character.name,
descr = character.appearance,
short_descr = character.appearance.split('.')[0],
age = character.age,
personality = character.personality,
occupation = character.occupation)
npc.following = player
player.location.insert(npc, None)


@property
def uptime(self) -> Tuple[int, int, int]:
Expand Down
20 changes: 17 additions & 3 deletions tale/driver_if.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@
from .tio import DEFAULT_SCREEN_DELAY
from .tio import iobase
from .llm_utils import LlmUtil

from tale.load_character import CharacterLoader

class IFDriver(driver.Driver):
"""
The Single user 'driver'.
Used to control interactive fiction where there's only one 'player'.
"""
def __init__(self, *, screen_delay: int=DEFAULT_SCREEN_DELAY, gui: bool=False, web: bool=False, wizard_override: bool=False) -> None:
def __init__(self, *, screen_delay: int=DEFAULT_SCREEN_DELAY, gui: bool=False, web: bool=False, wizard_override: bool=False, character_to_load: str='') -> None:
super().__init__()
self.game_mode = GameMode.IF
if screen_delay < 0 or screen_delay > 100:
Expand All @@ -42,6 +42,12 @@ def __init__(self, *, screen_delay: int=DEFAULT_SCREEN_DELAY, gui: bool=False, w
self.io_type = "web"
self.wizard_override = wizard_override
self.llm_util = LlmUtil()
if character_to_load:
character_loader = CharacterLoader()
if '.json' in character_to_load:
self.loaded_character = character_loader.load_from_json(character_to_load)
elif '.png' in character_to_load or '.jpg' in character_to_load:
self.loaded_character = character_loader.load_image(character_to_load)

def start_main_loop(self):
if self.io_type == "web":
Expand All @@ -51,6 +57,7 @@ def start_main_loop(self):
else:
print("written by", self.story.config.author)
connection = self.connect_player(self.io_type, self.screen_delay)
self.llm_util.connection = connection
if self.wizard_override:
connection.player.privileges.add("wizard")
# create the login dialog
Expand Down Expand Up @@ -153,7 +160,14 @@ def _login_dialog_if(self, conn: PlayerConnection) -> Generator:
conn.player.look(short=False) # force a 'look' command to get our bearings
return

if self.story.config.player_name:
if self.loaded_character:
name_info = charbuilder.PlayerNaming()
name_info.name = self.loaded_character.get('name')
name_info.stats.race = self.loaded_character.get('race', 'human')
name_info.gender = self.loaded_character.get('gender', 'm')
name_info.money = self.loaded_character.get('money', 0.0)
name_info.wizard = "wizard" in conn.player.privileges
elif self.story.config.player_name:
# story config provides a name etc.
name_info = charbuilder.PlayerNaming()
name_info.name = self.story.config.player_name
Expand Down
57 changes: 57 additions & 0 deletions tale/json_story.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import tale
from tale.base import Location, Item, Living
from tale.driver import Driver
from tale.player import Player
from tale.story import StoryBase, StoryConfig
import tale.parse_utils as parse_utils

class JsonStory(StoryBase):

def __init__(self, path: str, config: StoryConfig):
self.config = config
self.path = path
locs = {}
for zone in self.config.zones:
locs, exits = parse_utils.load_locations(parse_utils.load_json(self.path +'zones/'+zone + '.json'))
self._locations = locs
self.zones = locs
self._npcs = parse_utils.load_npcs(parse_utils.load_json(self.path +'npcs/'+self.config.npcs + '.json'), self._locations)
self._items = parse_utils.load_items(parse_utils.load_json(self.path + self.config.items + '.json'), self._locations)

def init(self, driver) -> None:
pass


def welcome(self, player: Player) -> str:
player.tell("<bright>Welcome to `%s'.</>" % self.config.name, end=True)
player.tell("\n")
player.tell("\n")
return ""

def welcome_savegame(self, player: Player) -> str:
return "" # not supported in demo

def goodbye(self, player: Player) -> None:
player.tell("Thanks for trying out Tale!")

def get_location(self, zone: str, name: str) -> Location:
return self._locations[zone][name]

def get_npc(self, npc: str) -> Living:
return self._npcs[npc]

def get_item(self, item: str) -> Item:
return self._items[item]

@property
def locations(self) -> dict:
return self._locations

@property
def npcs(self) -> dict:
return self._npcs

@property
def items(self) -> dict:
return self._items

18 changes: 12 additions & 6 deletions tale/llm_config.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
URL: "http://localhost:5001"
ENDPOINT: "/api/v1/generate"
DEFAULT_BODY: '{"stop_sequence": "\n\n", "max_length":300, "max_context_length":4096, "temperature":1.0, "top_k":120, "top_a":0.0, "top_p":0.85, "typical_p":1.0, "tfs":1.0, "rep_pen":1.2, "rep_pen_range":256, "mirostat":2, "mirostat_tau":5.0, "mirostat_eta":0.1, "sampler_order":[6,0,1,3,4,2,5], "seed":-1}'
MEMORY_SIZE: 1024
STREAM: False
STREAM_ENDPOINT: "/api/extra/generate/stream"
DATA_ENDPOINT: "/api/extra/generate/check"
WORD_LIMIT: 500
DEFAULT_BODY: '{"stop_sequence": "", "max_length":500, "max_context_length":4096, "temperature":1.0, "top_k":120, "top_a":0.0, "top_p":0.85, "typical_p":1.0, "tfs":1.0, "rep_pen":1.2, "rep_pen_range":256, "mirostat":2, "mirostat_tau":5.0, "mirostat_eta":0.1, "sampler_order":[6,0,1,3,4,2,5], "seed":-1}'
ANALYSIS_BODY: '{"banned_tokens":"\n\n", "stop_sequence": "", "max_length":500, "max_context_length":4096, "temperature":0.15, "top_k":120, "top_a":0.0, "top_p":0.85, "typical_p":1.0, "tfs":1.0, "rep_pen":1.2, "rep_pen_range":256, "mirostat":2, "mirostat_tau":5.0, "mirostat_eta":0.1, "sampler_order":[6,0,1,3,4,2,5], "seed":-1}'
MEMORY_SIZE: 512
PRE_PROMPT: 'Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.\n\n'
BASE_PROMPT: '### Instruction: Rewrite the following text in your own words using vivid language. Text:\n\n {input_text} \n\nEnd of text.\n\n### Response:\n\n'
DIALOGUE_PROMPT: 'The following is a conversation between {character1} and {character2}. {character2_description}. Chat history: {previous_conversation}\n\n### Instruction: Write a single response as {character2}, using {character2} description.\n\n### Response:\n'
ITEM_PROMPT: '### Instruction: Perform an analysis of the following text. Consider whether an item has been given, taken, dropped or put somewhere. Text:\n\n{text}\n\nEnd of text.\n\n Write your response in json format.\n\n Example {\n\n"thoughts":your thoughts on whether an item has been given, taken, dropped or put, and to whom/where.\n\n"result": {"item":"what item?", "from":"?", "to":"?"}\n\n}\n\nInsert the results from your analysis in "item", "from" and "to", or make them empty if no item was given, taken, dropped or put somewhere. Make sure the answer is valid json\n\n### Response:\n\n'
COMBAT_PROMPT: '### Instruction: Rewrite the following combat result into a vivid description. Write one to two paragraphs, ending in either death, or a stalemate. Combat Result: {result}\n\n### Response:\n\n'
BASE_PROMPT: "History: [{history}]. ### Instruction: Rewrite the following text in your own words using vivid language. 'History' can be used to create a context for what you write. Text:\n\n [{input_text}] \n\nEnd of text.\n\n### Response:\n"
DIALOGUE_PROMPT: 'The following is a conversation between {character1} and {character2}. {character2_description}. Chat history: {previous_conversation}\n\n {character2}s sentiment towards {character1}: {sentiment}. ### Instruction: Write a single response as {character2}, using {character2} description.\n\n### Response:\n'
ITEM_PROMPT: '### Instruction: Items:[{items}];Characters:[{character1},{character2}] Text:[{text}] \n\nIn the supplied text, was an item explicitly given, taken, dropped or put somewhere? Insert your thoughts about it in [my thoughts], and the results in "item", "from" and "to". Insert {character1}s sentiment towards {character2} in a single word in [sentiment assessment]. Write your response in JSON format. Example: {{ "thoughts":"[my thoughts]", "result": {{ "item":"", "from":"", "to":""}}, {{"sentiment":"[sentiment assessment]"}} }} End of example. \n\n Make sure the response is valid JSON\n\n### Response:\n'

COMBAT_PROMPT: '### Instruction: Rewrite the following combat result into a vivid description. Write one to two paragraphs, ending in either death, or a stalemate. Combat Result: {result}\n\n### Response:\n'


Loading

0 comments on commit f8e4950

Please sign in to comment.