Source code for textworld.generator.text_generation

# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT license.


import re
from collections import OrderedDict

from textworld.generator.game import Quest, Event, Game

from textworld.generator.text_grammar import Grammar
from textworld.generator.text_grammar import fix_determinant
from textworld.logic import Placeholder


[docs]class CountOrderedDict(OrderedDict): """ An OrderedDict whose empty items are 0 """ def __getitem__(self, item): if item not in self: super().__setitem__(item, 0) return super().__getitem__(item)
[docs]def assign_new_matching_names(obj1_infos, obj2_infos, grammar, exclude): if obj1_infos.name is not None or obj2_infos.name is not None: return False # One of the objects has already a name assigned to it. tag = "#({}<->{})_match#".format(obj1_infos.type, obj2_infos.type) if not grammar.has_tag(tag): return False found_matching_names = False for _ in range(50): result = grammar.expand(tag) first, second = result.split("<->") # Matching arguments are separated by '<->'. name1, adj1, noun1 = grammar.split_name_adj_noun(first.strip(), grammar.options.include_adj) name2, adj2, noun2 = grammar.split_name_adj_noun(second.strip(), grammar.options.include_adj) if name1 not in exclude and name2 not in exclude and name1 != name2: found_matching_names = True break if not found_matching_names: msg = ("Not enough variation for '{}'. You can add more variation " " in {} or turn on the 'include_adj=True' grammar flag." ).format(tag, grammar.obj_grammar_file) raise ValueError(msg) obj1_infos.name, obj1_infos.adj, obj1_infos.noun = name1, adj1, noun1 exclude.add(obj1_infos.name) obj2_infos.name, obj2_infos.adj, obj2_infos.noun = name2, adj2, noun2 exclude.add(obj2_infos.name) return True
[docs]def assign_name_to_object(obj, grammar, game_infos): """ Assign a name to an object (if needed). """ # TODO: use local exclude instead of grammar.used_names exclude = grammar.used_names obj_infos = game_infos[obj.id] if obj_infos.name is not None and not re.match("([a-z]_[0-9]+|P|I)", obj_infos.name): return # The name was already set. # Check if the object should match another one (i.e. same adjective). if obj.matching_entity_id is not None: other_obj_infos = game_infos[obj.matching_entity_id] success = assign_new_matching_names(obj_infos, other_obj_infos, grammar, exclude) if success: return # Try swapping the objects around i.e. match(o2, o1). success = assign_new_matching_names(other_obj_infos, obj_infos, grammar, exclude) if success: return # TODO: Should we enforce it? # Fall back on generating unmatching object name. values = grammar.generate_name(obj.type, room_type=obj_infos.room_type, exclude=exclude) obj_infos.name, obj_infos.adj, obj_infos.noun = values grammar.used_names.add(obj_infos.name)
[docs]def assign_description_to_object(obj, grammar, game): """ Assign a descripton to an object. """ if game.infos[obj.id].desc is not None: return # Already have a description. # Update the object description desc_tag = "#({})_desc#".format(obj.type) game.infos[obj.id].desc = "" if grammar.has_tag(desc_tag): game.infos[obj.id].desc = expand_clean_replace(desc_tag, grammar, obj, game) # If we have an openable object, append an additional description if game.kb.types.is_descendant_of(obj.type, ["c", "d"]): game.infos[obj.id].desc += grammar.expand(" #openable_desc#")
[docs]def generate_text_from_grammar(game, grammar: Grammar): # Assign a specific room type and name to our rooms for room in game.world.rooms: # First, generate a unique roomtype and name from the grammar if game.infos[room.id].room_type is None and grammar.has_tag("#room_type#"): game.infos[room.id].room_type = grammar.expand("#room_type#") assign_name_to_object(room, grammar, game.infos) # Next, assure objects contained in a room must have the same room type for obj in game.world.get_all_objects_in(room): if game.infos[obj.id].room_type is None: game.infos[obj.id].room_type = game.infos[room.id].room_type # Objects in inventory can be of any room type. for obj in game.world.get_objects_in_inventory(): if game.infos[obj.id].room_type is None and grammar.has_tag("#room_type#"): game.infos[obj.id].room_type = grammar.expand("#room_type#") # Assign name and description to objects. for obj in game.world.objects: if obj.type in ["I", "P"]: obj.name = obj.type continue assign_name_to_object(obj, grammar, game.infos) assign_description_to_object(obj, grammar, game) # Generate the room descriptions. for room in game.world.rooms: if game.infos[room.id].desc is None: # Skip rooms which already have a description. game.infos[room.id].desc = assign_description_to_room(room, game, grammar) # Generate the instructions. for quest in game.quests: if quest.desc is None: quest.desc = assign_description_to_quest(quest, game, grammar) return game
[docs]def assign_description_to_room(room, game, grammar): """ Assign a descripton to a room. """ # Add the decorative text room_desc = expand_clean_replace("#dec#\n\n", grammar, room, game) # Convert the objects into groupings based on adj/noun/type objs = [o for o in room.content if game.kb.types.is_descendant_of(o.type, game.kb.types.CLASS_HOLDER)] groups = OrderedDict() groups["adj"] = OrderedDict() groups["noun"] = OrderedDict() for obj in objs: obj_infos = game.infos[obj.id] adj, noun = obj_infos.adj, obj_infos.noun # get all grouped adjectives and nouns groups['adj'][adj] = list(filter(lambda x: game.infos[x.id].adj == adj, objs)) groups['noun'][noun] = list(filter(lambda x: game.infos[x.id].noun == noun, objs)) # Generate the room description, prioritizing group descriptions where possible ignore = [] for obj in objs: if obj.id in ignore: continue # Skip that object. obj_infos = game.infos[obj.id] adj, noun = obj_infos.adj, obj_infos.noun if grammar.options.blend_descriptions: found = False for type in ["noun", "adj"]: group_filt = [] if getattr(obj_infos, type) != "": group_filt = list(filter(lambda x: x.id not in ignore, groups[type][getattr(obj_infos, type)])) if len(group_filt) > 1: found = True desc = replace_num(grammar.expand("#room_desc_group#"), len(group_filt)) if type == "noun": desc = desc.replace("(val)", "{}s".format(getattr(obj_infos, type))) desc = desc.replace("(name)", obj_list_to_prop_string(group_filt, "adj", game, det_type="one")) elif type == "adj": _adj = getattr(obj_infos, type) if getattr(obj_infos, type) is not None else "" desc = desc.replace("(val)", "{}things".format(_adj)) desc = desc.replace("(name)", obj_list_to_prop_string(group_filt, "noun", game)) for o2 in group_filt: ignore.append(o2.id) if game.kb.types.is_descendant_of(o2.type, game.kb.types.CLASS_HOLDER): for vtype in [o2.type] + game.kb.types.get_ancestors(o2.type): tag = "#room_desc_({})_multi_{}#".format(vtype, "adj" if type == "noun" else "noun") if grammar.has_tag(tag): desc += expand_clean_replace(" " + tag, grammar, o2, game) break room_desc += " {}".format(fix_determinant(desc)) break if found: continue if obj.type not in ["P", "I", "d"]: for vtype in [obj.type] + game.kb.types.get_ancestors(obj.type): tag = "#room_desc_({})#".format(vtype) if grammar.has_tag(tag): room_desc += expand_clean_replace(" " + tag, grammar, obj, game) break room_desc += "\n\n" # Look for potential exit directions. exits_with_open_door = [] exits_with_closed_door = [] exits_without_door = [] for dir_ in sorted(room.exits.keys()): if dir_ in room.doors: door_obj = room.doors[dir_] attributes_names = [attr.name for attr in door_obj.get_attributes()] if "open" in attributes_names: exits_with_open_door.append((dir_, door_obj)) else: exits_with_closed_door.append((dir_, door_obj)) else: exits_without_door.append(dir_) exits_desc = [] # Describing exits with door. if grammar.options.blend_descriptions and len(exits_with_closed_door) > 1: dirs, door_objs = zip(*exits_with_closed_door) e_desc = grammar.expand("#room_desc_doors_closed#") e_desc = replace_num(e_desc, len(door_objs)) e_desc = e_desc.replace("(dir)", list_to_string(dirs, False)) e_desc = clean_replace_objs(grammar, e_desc, door_objs, game.infos) e_desc = repl_sing_plur(e_desc, len(door_objs)) exits_desc.append(e_desc) else: for dir_, door_obj in exits_with_closed_door: d_desc = expand_clean_replace(" #room_desc_(d)#", grammar, door_obj, game) d_desc = d_desc.replace("(dir)", dir_) exits_desc.append(d_desc) if grammar.options.blend_descriptions and len(exits_with_open_door) > 1: dirs, door_objs = zip(*exits_with_open_door) e_desc = grammar.expand("#room_desc_doors_open#") e_desc = replace_num(e_desc, len(door_objs)) e_desc = e_desc.replace("(dir)", list_to_string(dirs, False)) e_desc = clean_replace_objs(grammar, e_desc, door_objs, game.infos) e_desc = repl_sing_plur(e_desc, len(door_objs)) exits_desc.append(e_desc) else: for dir_, door_obj in exits_with_open_door: d_desc = expand_clean_replace(" #room_desc_(d)#", grammar, door_obj, game) d_desc = d_desc.replace("(dir)", dir_) exits_desc.append(d_desc) # Describing exits without door. if grammar.options.blend_descriptions and len(exits_without_door) > 1: e_desc = grammar.expand("#room_desc_exits#").replace("(dir)", list_to_string(exits_without_door, False)) e_desc = repl_sing_plur(e_desc, len(exits_without_door)) exits_desc.append(e_desc) else: for dir_ in exits_without_door: e_desc = grammar.expand("#room_desc_(dir)#").replace("(dir)", dir_) exits_desc.append(e_desc) room_desc += " ".join(exits_desc) # Finally, set the description return fix_determinant(room_desc)
[docs]class MergeAction: """ Group of actions merged into one. This allows for blending consecutive instructions. """ def __init__(self): self.name = "ig" self.const = [] self.mapping = OrderedDict() self.start = None self.end = None
[docs]def generate_instruction(action, grammar, game, counts): """ Generate text instruction for a specific action. """ # Get the more precise command tag. cmd_tag = "#{}#".format(action.name) if not grammar.has_tag(cmd_tag): cmd_tag = "#{}#".format(action.name.split("-")[0]) if not grammar.has_tag(cmd_tag): cmd_tag = "#{}#".format(action.name.split("-")[0].split("/")[0]) separator_tag = "#action_separator_{}#".format(action.name) if not grammar.has_tag(separator_tag): separator_tag = "#action_separator_{}#".format(action.name.split("-")[0]) if not grammar.has_tag(separator_tag): separator_tag = "#action_separator_{}#".format(action.name.split("-")[0].split("/")[0]) if not grammar.has_tag(separator_tag): separator_tag = "#action_separator#" if not grammar.has_tag(separator_tag): separator = "" else: separator = grammar.expand(separator_tag) desc = grammar.expand(cmd_tag) # We generate a custom mapping. mapping = OrderedDict() if isinstance(action, MergeAction): action_mapping = action.mapping else: action_mapping = game.kb.rules[action.name].match(action) for ph, var in action_mapping.items(): if var.type == "r": # We can use a simple description for the room r = game.world.find_room_by_id(var.name) # Match on 'name' if r is None: mapping[ph.name] = '' else: mapping[ph.name] = game.infos[r.id].name elif var.type in ["P", "I"]: continue else: # We want a more complex description for the objects obj = game.world.find_object_by_id(var.name) obj_infos = game.infos[obj.id] if grammar.options.ambiguous_instructions: assert False, "not tested" choices = [] for t in ["adj", "noun", "type"]: if counts[t][getattr(obj_infos, t)] <= 1: if t == "noun": choices.append(getattr(obj_infos, t)) elif t == "type": choices.append(game.kb.types.get_description(getattr(obj_infos, t))) else: # For adj, we pick an abstraction on the type atype = game.kb.types.get_description(grammar.rng.choice(game.kb.types.get_ancestors(obj.type))) choices.append("{} {}".format(getattr(obj_infos, t), atype)) # If we have no possibilities, use the name (ie. prioritize abstractions) if len(choices) == 0: choices.append(obj_infos.name) mapping[ph.name] = grammar.rng.choice(choices) else: mapping[ph.name] = obj_infos.name # Replace the keyword with one of the possibilities for keyword in re.findall(r'[(]\S*[)]', desc + separator): for key in keyword[1:-1].split("|"): if key in mapping: desc = desc.replace(keyword, mapping[key]) separator = separator.replace(keyword, mapping[key]) return desc, separator
[docs]def assign_description_to_quest(quest: Quest, game: Game, grammar: Grammar): event_descriptions = [] for event in quest.win_events: event_descriptions += [describe_event(event, game, grammar)] quest_desc = " OR ".join(desc for desc in event_descriptions if desc) return quest_desc
[docs]def describe_event(event: Event, game: Game, grammar: Grammar) -> str: """ Assign a descripton to a quest. """ # We have to "count" all the adj/noun/types in the world # This is important for using "unique" but abstracted references to objects counts = OrderedDict() counts["adj"] = CountOrderedDict() counts["noun"] = CountOrderedDict() counts["type"] = CountOrderedDict() # Assign name and description to objects. for obj in game.world.objects: if obj.type in ["I", "P"]: continue obj_infos = game.infos[obj.id] counts['adj'][obj_infos.adj] += 1 counts['noun'][obj_infos.noun] += 1 counts['type'][obj.type] += 1 if len(event.actions) == 0: # We don't need to say anything if the quest is empty event_desc = "" else: # Generate a description for either the last, or all commands if grammar.options.only_last_action: actions_desc, _ = generate_instruction(event.actions[-1], grammar, game, counts) only_one_action = True else: actions_desc_list = [] # Decide if we blend instructions together or not if grammar.options.blend_instructions: instructions = get_action_chains(event.actions, grammar, game) else: instructions = event.actions only_one_action = len(instructions) < 2 for c in instructions: desc, separator = generate_instruction(c, grammar, game, counts) actions_desc_list.append(desc) if c != instructions[-1] and len(separator) > 0: actions_desc_list.append(separator) actions_desc = " ".join(actions_desc_list) if only_one_action: quest_tag = grammar.get_random_expansion("#quest_one_action#") quest_tag = quest_tag.replace("(action)", actions_desc.strip()) else: quest_tag = grammar.get_random_expansion("#quest#") quest_tag = quest_tag.replace("(list_of_actions)", actions_desc.strip()) event_desc = grammar.expand(quest_tag) event_desc = re.sub(r"(^|(?<=[?!.]))\s*([a-z])", lambda pat: pat.group(1) + ' ' + pat.group(2).upper(), event_desc) return event_desc
[docs]def get_action_chains(actions, grammar, game): """ Reduce the action list by combining similar actions. """ seq_lim = -1 sequences = [] # Greedily get the collection of sequences for size in range(len(actions), 1, -1): for start in range(len(actions) - size + 1): if start > seq_lim: is_sequence, seq = is_seq(actions[start:start + size], game) if is_sequence and grammar.has_tag("#{}#".format(seq.name)): seq.start = start seq.end = start + size sequences.append(seq) seq_lim = start + size # Now build the reduced list of actions final_seq = [] i = 0 while (i < len(actions)): if len(sequences) > 0 and sequences[0].start == i: i = sequences[0].end final_seq.append(sequences[0]) sequences.pop(0) else: final_seq.append(actions[i]) i += 1 return final_seq
[docs]def is_seq(chain, game): """ Check if we have a theoretical chain in actions. """ seq = MergeAction() room_placeholder = Placeholder('r') action_mapping = game.kb.rules[chain[0].name].match(chain[0]) for ph, var in action_mapping.items(): if ph.type not in ["P", "I"]: seq.mapping[ph] = var seq.const.append(var) for c in chain: c_action_mapping = game.kb.rules[c.name].match(c) # Update our action name seq.name += "_{}".format(c.name.split("/")[0]) # We break a chain if we move rooms if c_action_mapping[room_placeholder] != seq.mapping[room_placeholder]: return False, seq # Update the mapping for ph, var in c_action_mapping.items(): if ph.type not in ["P", "I"]: if ph in seq.mapping and var != seq.mapping[ph]: return False, seq else: seq.mapping[ph] = var # Remove any objects that we no longer use tmp = list(filter(lambda x: x in c_action_mapping.values(), seq.const)) # If all original objects are gone, the seq is broken if len(tmp) == 0: return False, seq # Update our obj list seq.const = tmp return True, seq
[docs]def replace_num(phrase, val): """ Add a numerical value to a string. """ if val == 1: return phrase.replace("(^)", "one") elif val == 2: return phrase.replace("(^)", "two") else: return phrase.replace("(^)", "several")
[docs]def expand_clean_replace(symbol, grammar, obj, game): """ Return a cleaned/keyword replaced symbol. """ obj_infos = game.infos[obj.id] phrase = grammar.expand(symbol) phrase = phrase.replace("(obj)", obj_infos.id) phrase = phrase.replace("(name)", obj_infos.name) phrase = phrase.replace("(name-n)", obj_infos.noun if obj_infos.adj is not None else obj_infos.name) phrase = phrase.replace("(name-adj)", obj_infos.adj if obj_infos.adj is not None else grammar.expand("#ordinary_adj#")) if obj.type != "": phrase = phrase.replace("(name-t)", game.kb.types.get_description(obj.type)) else: assert False, "Does this even happen?" return fix_determinant(phrase)
[docs]def clean_replace_objs(grammar, desc, objs, game): """ Return a cleaned/keyword replaced for a list of objects. """ desc = desc.replace("(obj)", obj_list_to_prop_string(objs, "id", game, det=False)) desc = desc.replace("(name)", obj_list_to_prop_string(objs, "name", game, det=False)) desc = desc.replace("(name-n)", obj_list_to_prop_string(objs, "noun", game, det=False)) desc = desc.replace("(name-adj)", obj_list_to_prop_string(objs, "adj", game, det=False)) desc = desc.replace("(name-definite)", obj_list_to_prop_string(objs, "name", game, det=True, det_type="the")) desc = desc.replace("(name-indefinite)", obj_list_to_prop_string(objs, "name", game, det=True, det_type="a")) desc = desc.replace("(name-n-definite)", obj_list_to_prop_string(objs, "noun", game, det=True, det_type="the")) desc = desc.replace("(name-n-indefinite)", obj_list_to_prop_string(objs, "noun", game, det=True, det_type="a")) return desc
[docs]def repl_sing_plur(phrase, length): """ Alter a sentence depending on whether or not we are dealing with plural or singular objects (for counting) """ for r in re.findall(r'[\[][^\[]*\|[^\[]*[\]]', phrase): if length > 1: phrase = phrase.replace(r, r[1:-1].split("|")[1]) else: phrase = phrase.replace(r, r[1:-1].split("|")[0]) return phrase
[docs]def obj_list_to_prop_string(objs, property, game, det=True, det_type="a"): """ Convert an object list to a nl string list of names. """ return list_to_string(list(map(lambda obj: getattr(game.infos[obj.id], property), objs)), det=det, det_type=det_type)
[docs]def list_to_string(lst, det, det_type="a"): """ Convert a list to a natural language string. """ string = "" if len(lst) == 1: return "{}{}".format(det_type + " " if det else "", lst[0]) for i in range(len(lst)): if i >= (len(lst) - 1): string = "{} and {}{}".format(string[:-2], "{} ".format(det_type) if det else "", lst[i]) else: string += "{}{}, ".format("{} ".format(det_type) if det else "", lst[i]) return string