From 1ecb92b961e13fc9d85d113b0e96e7905c492185 Mon Sep 17 00:00:00 2001 From: rickard Date: Sun, 13 Aug 2023 09:50:56 +0200 Subject: [PATCH] llm generates characters roaming characters json sanitization deluxe --- stories/prancingllama/npcs/npcs.py | 23 +++++++++++++++ stories/prancingllama/zones/prancingllama.py | 31 ++++++++++++++++++++ tale/llm_config.yaml | 5 ++-- tale/llm_utils.py | 30 +++++++++++++++++-- tale/load_character.py | 2 +- tale/parse_utils.py | 7 +++++ tale/verbdefs.py | 1 - tests/test_character_loader.py | 19 +++++++----- 8 files changed, 103 insertions(+), 15 deletions(-) diff --git a/stories/prancingllama/npcs/npcs.py b/stories/prancingllama/npcs/npcs.py index 6c41c840..804a4fa3 100644 --- a/stories/prancingllama/npcs/npcs.py +++ b/stories/prancingllama/npcs/npcs.py @@ -56,6 +56,29 @@ def do_pick_up_dishes(self, ctx: Context) -> None: self.location.tell(f"{lang.capital(self.title)} wipes a table and picks up dishes.", evoke=False, max_length=True) +class RoamingPatron(LivingNpc): + + + def __init__(self, name: str, gender: str, *, + title: str="", descr: str="", short_descr: str="", age: int, personality: str): + super(RoamingPatron, self).__init__(name=name, gender=gender, + title=title, descr=descr, short_descr=short_descr, age=age, personality=personality, occupation='') + self.sitting = False + + @call_periodically(45, 120) + def do_random_move(self, ctx: Context) -> None: + if not self.sitting: + if random.random() < 0.25: + self.sitting = True + self.tell_others("{Actor} sits down.", evoke=False, max_length=True) + else: + direction = self.select_random_move() + if direction: + self.move(direction.target, self, direction_names=direction.names) + elif random.random() < 0.5: + self.sitting = False + self.tell_others("{Actor} stands up.", evoke=False, max_length=True) + class Patron(LivingNpc): diff --git a/stories/prancingllama/zones/prancingllama.py b/stories/prancingllama/zones/prancingllama.py index be2af257..82c008e4 100644 --- a/stories/prancingllama/zones/prancingllama.py +++ b/stories/prancingllama/zones/prancingllama.py @@ -6,6 +6,7 @@ from tale.player import Player from tale.util import Context, call_periodically from tale.verbdefs import AGGRESSIVE_VERBS +from tale.verbdefs import VERBS from npcs.npcs import * @@ -57,3 +58,33 @@ def spawn_rat(self, ctx: Context) -> None: norhardt.init_inventory([old_map]) +all_locations = [main_hall, bar, kitchen, hearth, entrance, outside, cellar] + +def _generate_character(): + # select 5 random verbs from VERBS + verbs = [] + for i in range(5): + verbs.append(random.choice(list(VERBS.keys()))) + + character = mud_context.driver.llm_util.generate_character(story_context=mud_context.config.context, keywords=verbs) # Characterv2 + if character: + patron = RoamingPatron(character.name, + gender=character.gender, + title=lang.capital(character.name), + descr=character.description, + short_descr=character.appearance, + age=character.age, + personality=character.personality) + patron.aliases = [character.name.split(' ')[0]] + location = all_locations[random.randint(0, len(all_locations) - 1)] + location.insert(patron, None) + return True + return False + +# 10 attempts to generate 2 characters +generated = 0 +for i in range(10): + if _generate_character(): + generated += 1 + if generated == 2: + break diff --git a/tale/llm_config.yaml b/tale/llm_config.yaml index 35dd4b05..4dcdf58f 100644 --- a/tale/llm_config.yaml +++ b/tale/llm_config.yaml @@ -11,5 +11,6 @@ PRE_PROMPT: 'Below is an instruction that describes a task, paired with an input BASE_PROMPT: "[Story context: {story_context}]; [History: {history}]; ### Instruction: Rewrite the following Text in your own words using the supplied Context and History to create a background for your text. Use about {max_words} words. Text:\n\n [{input_text}] \n\nEnd of text.\n\n### Response:\n" ACTION_PROMPT: "[Story context: {story_context}]; [History: {history}]; The following Action is part of a roleplaying game. ### Instruction: Rewrite the Action, and nothing else, in your own words using the supplied Context and Location. History is what happened before. Use less than {max_words} words. Text:\n\n [{input_text}] \n\nEnd of text.\n\n### Response:\n" DIALOGUE_PROMPT: '[Story context: {story_context}]; [Location: {location}]; The following is a conversation between {character1} and {character2}; {character1}:{character1_description}; character2:{character2_description}; [Chat history: {previous_conversation}]\n\n [{character2}s sentiment towards {character1}: {sentiment}]. ### Instruction: Write a single response for {character2} in third person pov, using {character2} description.\n\n### Response:\n' -ITEM_PROMPT: 'Items:[{items}];Characters:[{character1},{character2}] \n\n ### Instruction: Decide if there was an item explicitly given, taken, dropped or put somewhere in the following text:[Text:{text}]. Insert your thoughts about whether an item was explicitly given, taken, put somewhere or dropped in "my thoughts", and the results in "item", "from" and "to", or make them empty if . Insert {character1}s sentiment towards {character2} in a single word in "sentiment assessment". Example: {{ "thoughts":"my thoughts", "result": {{ "item":"", "from":"", "to":""}}, {{"sentiment":"sentiment assessment"}} }} End of example. \n\n Write your response in valid JSON\n\n### Response:\n' -COMBAT_PROMPT: 'Rewrite the following combat between user {attacker} and {victim} into a vivid description in less than 300 words. Location: {location}, {location_description}. ### Instruction: Rewrite the following combat result in about 150 words. Combat Result: {attacker_msg} ### Response:\n\n' \ No newline at end of file +ITEM_PROMPT: 'Items:[{items}];Characters:[{character1},{character2}] \n\n ### Instruction: Decide if an item was explicitly given, taken, dropped or put somewhere in the following text:[Text:{text}]. Insert your thoughts about whether an item was explicitly given, taken, put somewhere or dropped in "my thoughts", and the results in "item", "from" and "to", or make them empty if . Insert {character1}s sentiment towards {character2} in a single word in "sentiment assessment". Fill in the following JSON template: {{ "thoughts":"my thoughts", "result": {{ "item":"", "from":"", "to":""}}, {{"sentiment":"sentiment assessment"}} }} End of example. \n\n Write your response in valid JSON\n\n### Response:\n' +COMBAT_PROMPT: 'Rewrite the following combat between user {attacker} and {victim} into a vivid description in less than 300 words. Location: {location}, {location_description}. ### Instruction: Rewrite the following combat result in about 150 words. Combat Result: {attacker_msg} ### Response:\n\n' +CREATE_CHARACTER_PROMPT: '### Instruction: For a low-fantasy roleplaying game, create a diverse character with rich personality that can be interacted with using the story context and keywords. Do not mention height. Money as int value. Story context: {story_context}; keywords: {keywords}. Fill in this JSON template and write nothing else: {{"name":"", "description": "", "appearance": "", "personality": "", "money":"", "level":"", "gender":"", "age":"", "race":""}} ### Response:\n' \ No newline at end of file diff --git a/tale/llm_utils.py b/tale/llm_utils.py index 3e4da0ad..71e065b7 100644 --- a/tale/llm_utils.py +++ b/tale/llm_utils.py @@ -3,8 +3,9 @@ import yaml from json import JSONDecodeError from tale.llm_io import IoUtil -import tale.parse_utils as parse_utils +from tale.load_character import CharacterV2 from tale.player_utils import TextBuffer +import tale.parse_utils as parse_utils class LlmUtil(): """ Prepares prompts for various LLM requests""" @@ -27,6 +28,7 @@ def __init__(self): self.dialogue_prompt = config_file['DIALOGUE_PROMPT'] self.action_prompt = config_file['ACTION_PROMPT'] self.combat_prompt = config_file['COMBAT_PROMPT'] + self.character_prompt = config_file['CREATE_CHARACTER_PROMPT'] self.item_prompt = config_file['ITEM_PROMPT'] self.word_limit = config_file['WORD_LIMIT'] self._story_background = '' @@ -88,7 +90,7 @@ def dialogue_analysis(self, text: str, character_card: str, character_name: str, request_body['prompt'] = prompt text = parse_utils.trim_response(self.io_util.synchronous_request(self.url + self.endpoint, request_body)) try: - json_result = json.loads(text.replace('\n', '')) + json_result = json.loads(parse_utils.sanitize_json(text)) except JSONDecodeError as exc: print(exc) return None, None @@ -133,7 +135,29 @@ def update_memory(self, rolling_prompt: str, response_text: str): if len(rolling_prompt) > self.memory_size: rolling_prompt = rolling_prompt[len(rolling_prompt) - self.memory_size + 1:] return rolling_prompt - + + def generate_character(self, story_context: str = '', keywords: list = []): + """ Generate a character card based on the current story context""" + prompt = self.character_prompt.format(story_context=story_context, + keywords=', '.join(keywords)) + request_body = self.default_body + request_body['stop_sequence'] = ['\n\n'] + request_body['temperature'] = 1.0 + request_body['banned_tokens'] = ['```'] + request_body['prompt'] = prompt + result = self.io_util.synchronous_request(self.url + self.endpoint, request_body) + try: + json_result = json.loads(parse_utils.sanitize_json(result)) + except JSONDecodeError as exc: + print(exc) + return None + try: + return CharacterV2().from_json(json_result) + except: + print(f'Exception while parsing character {json_result}') + return None + + @property def story_background(self) -> str: return self._story_background diff --git a/tale/load_character.py b/tale/load_character.py index e740e89d..08ef6b42 100644 --- a/tale/load_character.py +++ b/tale/load_character.py @@ -60,7 +60,7 @@ def __init__(self, name: str='', def from_json(self, json: dict): self.name = json.get('name') self.race = json.get('race', 'human') - self.gender = json.get('gender', 'f') + self.gender = json.get('gender', 'f')[0].lower() description = json.get('description') self.description = description self.appearance = json.get('appearance', description.split(';')[0]) diff --git a/tale/parse_utils.py b/tale/parse_utils.py index 4cb05909..18837bb5 100644 --- a/tale/parse_utils.py +++ b/tale/parse_utils.py @@ -139,3 +139,10 @@ def trim_response(message: str): if last > lastChar: lastChar = last return message[:lastChar+1] + +def sanitize_json(result: str): + """ Removes special chars from json string. Some common, and some 'creative' ones. """ + # .replace('}}', '}') + result = result.replace('\\"', '"').replace('"\\n"', '","').replace('\\n', '').replace('}\n{', '},{').replace('}{', '},{').replace('\\r', '').replace('\\t', '').replace('"{', '{').replace('}"', '}').replace('"\\', '"').replace('""', '"').replace('\\”', '"').replace('" "', '","').replace(':,',':') + print('sanitized json: ' + result) + return result \ No newline at end of file diff --git a/tale/verbdefs.py b/tale/verbdefs.py index 9eb0622a..3bf4bb80 100644 --- a/tale/verbdefs.py +++ b/tale/verbdefs.py @@ -332,7 +332,6 @@ "duck": (PERS, None, "duck$ \nHOW out of the way", "duck$ \nHOW out of \nPOSS way"), } # type: Dict[str, Tuple] - assert all(v[1] is None or type(v[1]) is tuple for v in VERBS.values()), "Second specifier in verb list must be None or tuple, not str" AGGRESSIVE_VERBS = { diff --git a/tests/test_character_loader.py b/tests/test_character_loader.py index ccea9ff9..6600eb07 100644 --- a/tests/test_character_loader.py +++ b/tests/test_character_loader.py @@ -19,31 +19,34 @@ def test_load_image(self): assert(json_data.get('description')) def test_load_from_json(self): - path = 'tests/files/riley.json' + path = 'tests/files/test_character.json' char_data = self.character_loader.load_character(path) assert(char_data) - assert(char_data.get('name')) + assert(char_data.get('name') == 'test character') assert(char_data.get('description')) def test_CharacterV2(self): - path = 'tests/files/riley.json' + path = 'tests/files/test_character.json' char_data = self.character_loader.load_character(path) description = char_data.get('description') character = CharacterV2(name = char_data.get('name'), race = char_data.get('race', 'human'), gender = char_data.get('gender', 'm'), money = char_data.get('money', 0.0), - description = description) + appearance = char_data.get('appearance', ''), + description = description, + aliases = char_data.get('aliases', [])) self._verify_character(character) def test_CharacterV2_from_json(self): - path = 'tests/files/riley.json' + path = 'tests/files/test_character.json' char_data = self.character_loader.load_character(path) character = CharacterV2().from_json(char_data) self._verify_character(character) def _verify_character(self, character: CharacterV2): - assert(character.name) - assert(character.appearance) - assert(character.description) + assert(character.name == 'test character') + assert(character.appearance == 'test appearance') + assert(character.description == 'test description') + assert(character.aliases == ['alias1', 'alias2'])