Skip to content

Commit a436f9e

Browse files
authored
Merge pull request #8 from neph1/llm_char_gen
llm generates characters
2 parents 67d7919 + 1ecb92b commit a436f9e

File tree

8 files changed

+103
-15
lines changed

8 files changed

+103
-15
lines changed

stories/prancingllama/npcs/npcs.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,29 @@ def do_pick_up_dishes(self, ctx: Context) -> None:
5656
self.location.tell(f"{lang.capital(self.title)} wipes a table and picks up dishes.", evoke=False, max_length=True)
5757

5858

59+
class RoamingPatron(LivingNpc):
60+
61+
62+
def __init__(self, name: str, gender: str, *,
63+
title: str="", descr: str="", short_descr: str="", age: int, personality: str):
64+
super(RoamingPatron, self).__init__(name=name, gender=gender,
65+
title=title, descr=descr, short_descr=short_descr, age=age, personality=personality, occupation='')
66+
self.sitting = False
67+
68+
@call_periodically(45, 120)
69+
def do_random_move(self, ctx: Context) -> None:
70+
if not self.sitting:
71+
if random.random() < 0.25:
72+
self.sitting = True
73+
self.tell_others("{Actor} sits down.", evoke=False, max_length=True)
74+
else:
75+
direction = self.select_random_move()
76+
if direction:
77+
self.move(direction.target, self, direction_names=direction.names)
78+
elif random.random() < 0.5:
79+
self.sitting = False
80+
self.tell_others("{Actor} stands up.", evoke=False, max_length=True)
81+
5982

6083
class Patron(LivingNpc):
6184

stories/prancingllama/zones/prancingllama.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from tale.player import Player
77
from tale.util import Context, call_periodically
88
from tale.verbdefs import AGGRESSIVE_VERBS
9+
from tale.verbdefs import VERBS
910
from npcs.npcs import *
1011

1112

@@ -57,3 +58,33 @@ def spawn_rat(self, ctx: Context) -> None:
5758

5859
norhardt.init_inventory([old_map])
5960

61+
all_locations = [main_hall, bar, kitchen, hearth, entrance, outside, cellar]
62+
63+
def _generate_character():
64+
# select 5 random verbs from VERBS
65+
verbs = []
66+
for i in range(5):
67+
verbs.append(random.choice(list(VERBS.keys())))
68+
69+
character = mud_context.driver.llm_util.generate_character(story_context=mud_context.config.context, keywords=verbs) # Characterv2
70+
if character:
71+
patron = RoamingPatron(character.name,
72+
gender=character.gender,
73+
title=lang.capital(character.name),
74+
descr=character.description,
75+
short_descr=character.appearance,
76+
age=character.age,
77+
personality=character.personality)
78+
patron.aliases = [character.name.split(' ')[0]]
79+
location = all_locations[random.randint(0, len(all_locations) - 1)]
80+
location.insert(patron, None)
81+
return True
82+
return False
83+
84+
# 10 attempts to generate 2 characters
85+
generated = 0
86+
for i in range(10):
87+
if _generate_character():
88+
generated += 1
89+
if generated == 2:
90+
break

tale/llm_config.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ PRE_PROMPT: 'Below is an instruction that describes a task, paired with an input
1111
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"
1212
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"
1313
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'
14-
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'
15-
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'
14+
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'
15+
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'
16+
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'

tale/llm_utils.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import yaml
44
from json import JSONDecodeError
55
from tale.llm_io import IoUtil
6-
import tale.parse_utils as parse_utils
6+
from tale.load_character import CharacterV2
77
from tale.player_utils import TextBuffer
8+
import tale.parse_utils as parse_utils
89

910
class LlmUtil():
1011
""" Prepares prompts for various LLM requests"""
@@ -27,6 +28,7 @@ def __init__(self):
2728
self.dialogue_prompt = config_file['DIALOGUE_PROMPT']
2829
self.action_prompt = config_file['ACTION_PROMPT']
2930
self.combat_prompt = config_file['COMBAT_PROMPT']
31+
self.character_prompt = config_file['CREATE_CHARACTER_PROMPT']
3032
self.item_prompt = config_file['ITEM_PROMPT']
3133
self.word_limit = config_file['WORD_LIMIT']
3234
self._story_background = ''
@@ -88,7 +90,7 @@ def dialogue_analysis(self, text: str, character_card: str, character_name: str,
8890
request_body['prompt'] = prompt
8991
text = parse_utils.trim_response(self.io_util.synchronous_request(self.url + self.endpoint, request_body))
9092
try:
91-
json_result = json.loads(text.replace('\n', ''))
93+
json_result = json.loads(parse_utils.sanitize_json(text))
9294
except JSONDecodeError as exc:
9395
print(exc)
9496
return None, None
@@ -133,7 +135,29 @@ def update_memory(self, rolling_prompt: str, response_text: str):
133135
if len(rolling_prompt) > self.memory_size:
134136
rolling_prompt = rolling_prompt[len(rolling_prompt) - self.memory_size + 1:]
135137
return rolling_prompt
136-
138+
139+
def generate_character(self, story_context: str = '', keywords: list = []):
140+
""" Generate a character card based on the current story context"""
141+
prompt = self.character_prompt.format(story_context=story_context,
142+
keywords=', '.join(keywords))
143+
request_body = self.default_body
144+
request_body['stop_sequence'] = ['\n\n']
145+
request_body['temperature'] = 1.0
146+
request_body['banned_tokens'] = ['```']
147+
request_body['prompt'] = prompt
148+
result = self.io_util.synchronous_request(self.url + self.endpoint, request_body)
149+
try:
150+
json_result = json.loads(parse_utils.sanitize_json(result))
151+
except JSONDecodeError as exc:
152+
print(exc)
153+
return None
154+
try:
155+
return CharacterV2().from_json(json_result)
156+
except:
157+
print(f'Exception while parsing character {json_result}')
158+
return None
159+
160+
137161
@property
138162
def story_background(self) -> str:
139163
return self._story_background

tale/load_character.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def __init__(self, name: str='',
6060
def from_json(self, json: dict):
6161
self.name = json.get('name')
6262
self.race = json.get('race', 'human')
63-
self.gender = json.get('gender', 'f')
63+
self.gender = json.get('gender', 'f')[0].lower()
6464
description = json.get('description')
6565
self.description = description
6666
self.appearance = json.get('appearance', description.split(';')[0])

tale/parse_utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,10 @@ def trim_response(message: str):
139139
if last > lastChar:
140140
lastChar = last
141141
return message[:lastChar+1]
142+
143+
def sanitize_json(result: str):
144+
""" Removes special chars from json string. Some common, and some 'creative' ones. """
145+
# .replace('}}', '}')
146+
result = result.replace('\\"', '"').replace('"\\n"', '","').replace('\\n', '').replace('}\n{', '},{').replace('}{', '},{').replace('\\r', '').replace('\\t', '').replace('"{', '{').replace('}"', '}').replace('"\\', '"').replace('""', '"').replace('\\”', '"').replace('" "', '","').replace(':,',':')
147+
print('sanitized json: ' + result)
148+
return result

tale/verbdefs.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,6 @@
332332
"duck": (PERS, None, "duck$ \nHOW out of the way", "duck$ \nHOW out of \nPOSS way"),
333333
} # type: Dict[str, Tuple]
334334

335-
336335
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"
337336

338337
AGGRESSIVE_VERBS = {

tests/test_character_loader.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,34 @@ def test_load_image(self):
1919
assert(json_data.get('description'))
2020

2121
def test_load_from_json(self):
22-
path = 'tests/files/riley.json'
22+
path = 'tests/files/test_character.json'
2323
char_data = self.character_loader.load_character(path)
2424
assert(char_data)
25-
assert(char_data.get('name'))
25+
assert(char_data.get('name') == 'test character')
2626
assert(char_data.get('description'))
2727

2828
def test_CharacterV2(self):
29-
path = 'tests/files/riley.json'
29+
path = 'tests/files/test_character.json'
3030
char_data = self.character_loader.load_character(path)
3131
description = char_data.get('description')
3232
character = CharacterV2(name = char_data.get('name'),
3333
race = char_data.get('race', 'human'),
3434
gender = char_data.get('gender', 'm'),
3535
money = char_data.get('money', 0.0),
36-
description = description)
36+
appearance = char_data.get('appearance', ''),
37+
description = description,
38+
aliases = char_data.get('aliases', []))
3739
self._verify_character(character)
3840

3941
def test_CharacterV2_from_json(self):
40-
path = 'tests/files/riley.json'
42+
path = 'tests/files/test_character.json'
4143
char_data = self.character_loader.load_character(path)
4244
character = CharacterV2().from_json(char_data)
4345
self._verify_character(character)
4446

4547
def _verify_character(self, character: CharacterV2):
46-
assert(character.name)
47-
assert(character.appearance)
48-
assert(character.description)
48+
assert(character.name == 'test character')
49+
assert(character.appearance == 'test appearance')
50+
assert(character.description == 'test description')
51+
assert(character.aliases == ['alias1', 'alias2'])
4952

0 commit comments

Comments
 (0)