# coding=utf-8 # Copyright 2023 The Google Research Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Library of instructions.""" import collections import json import logging import random import re import string from typing import Dict, Optional, Sequence, Union import langdetect from lm_eval.tasks.ifeval import instructions_util logger = logging.getLogger(__name__) _InstructionArgsDtype = Optional[Dict[str, Union[int, str, Sequence[str]]]] _LANGUAGES = instructions_util.LANGUAGE_CODES # The relational operation for comparison. _COMPARISON_RELATION = ("less than", "at least") # The maximum number of sentences. _MAX_NUM_SENTENCES = 20 # The number of placeholders. _NUM_PLACEHOLDERS = 4 # The number of bullet lists. _NUM_BULLETS = 5 # The options of constrained response. _CONSTRAINED_RESPONSE_OPTIONS = ( "My answer is yes.", "My answer is no.", "My answer is maybe.", ) # The options of starter keywords. _STARTER_OPTIONS = ( "I would say", "My answer is", "I believe", "In my opinion", "I think", "I reckon", "I feel", "From my perspective", "As I see it", "According to me", "As far as I'm concerned", "To my understanding", "In my view", "My take on it is", "As per my perception", ) # The options of ending keywords. # TODO(jeffreyzhou) add more ending options _ENDING_OPTIONS = ("Any other questions?", "Is there anything else I can help with?") # The number of highlighted sections. _NUM_HIGHLIGHTED_SECTIONS = 4 # The section spliter. _SECTION_SPLITER = ("Section", "SECTION") # The number of sections. _NUM_SECTIONS = 5 # The number of paragraphs. _NUM_PARAGRAPHS = 5 # The postscript marker. _POSTSCRIPT_MARKER = ("P.S.", "P.P.S") # The number of keywords. _NUM_KEYWORDS = 2 # The occurrences of a single keyword. _KEYWORD_FREQUENCY = 3 # The occurrences of a single letter. _LETTER_FREQUENCY = 10 # The occurrences of words with all capital letters. _ALL_CAPITAL_WORD_FREQUENCY = 20 # The number of words in the response. _NUM_WORDS_LOWER_LIMIT = 100 _NUM_WORDS_UPPER_LIMIT = 500 class Instruction: """An instruction template.""" def __init__(self, instruction_id): self.id = instruction_id def build_description(self, **kwargs): raise NotImplementedError("`build_description` not implemented.") def get_instruction_args(self): raise NotImplementedError("`get_instruction_args` not implemented.") def get_instruction_args_keys(self): raise NotImplementedError("`get_instruction_args_keys` not implemented.") def check_following(self, value): raise NotImplementedError("`check_following` not implemented.") class ResponseLanguageChecker(Instruction): """Check the language of the entire response.""" def build_description(self, *, language=None): """Build the instruction description. Args: language: A string representing the expected language of the response. The language has to comply to the 97 types defined in `langid.py` (https://pypi.org/project/langid/1.1.5/), which follows ISO 639-1 codes (https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes); for example, `en` for English, `zh` for Chinese, `fr` for French. Returns: A string representing the instruction description. """ self._language = language if self._language is None: self._language = random.choice(list(_LANGUAGES.keys())) # TODO(tianjianlu): opens the description generation to more choices. self._description_pattern = ( "Your ENTIRE response should be in {language} language, no other " + "language is allowed." ) return self._description_pattern.format(language=_LANGUAGES[self._language]) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return {"language": self._language} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["language"] def check_following(self, value): """Check if the language of the entire response follows the instruction. Args: value: A string representing the response. Returns: True if the language of `value` follows instruction; otherwise False. """ assert isinstance(value, str) try: return langdetect.detect(value) == self._language except langdetect.LangDetectException as e: # Count as instruction is followed. logging.error( "Unable to detect language for text %s due to %s", value, e ) # refex: disable=pytotw.037 return True class NumberOfSentences(Instruction): """Check the number of sentences.""" def build_description(self, *, num_sentences=None, relation=None): """Build the instruction description. Args: num_sentences: An integer specifying the number of sentences as a threshold. relation: A string in (`less than`, `at least`), defining the relational operator for comparison. Two relational comparisons are supported for now: if 'less than', the actual number of sentences < the threshold; if 'at least', the actual number of sentences >= the threshold. Returns: A string representing the instruction description. """ # The number of sentences as a threshold for comparison. self._num_sentences_threshold = num_sentences if self._num_sentences_threshold is None or self._num_sentences_threshold < 0: self._num_sentences_threshold = random.randint(1, _MAX_NUM_SENTENCES) if relation is None: self._comparison_relation = random.choice(_COMPARISON_RELATION) elif relation not in _COMPARISON_RELATION: raise ValueError( "The supported relation for comparison must be in " f"{_COMPARISON_RELATION}, but {relation} is given." ) else: self._comparison_relation = relation self._description_pattern = ( "Your response should contain {relation} {num_sentences} sentences." ) return self._description_pattern.format( relation=self._comparison_relation, num_sentences=self._num_sentences_threshold, ) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return { "num_sentences": self._num_sentences_threshold, "relation": self._comparison_relation, } def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["num_sentences", "relation"] def check_following(self, value): """Check if the number of sentences follows the instruction. Args: value: A string representing the response. Returns: True if the response follows the instruction. Raise: ValueError if the string in `instruction_args` is not in [`less_than`, `at_least`]. """ num_sentences = instructions_util.count_sentences(value) if self._comparison_relation == _COMPARISON_RELATION[0]: return num_sentences < self._num_sentences_threshold elif self._comparison_relation == _COMPARISON_RELATION[1]: return num_sentences >= self._num_sentences_threshold class PlaceholderChecker(Instruction): """Check the placeholders in template writing.""" def build_description(self, *, num_placeholders=None): """Build the instruction description. Args: num_placeholders: An integer denoting the minimum number of placeholders required in the response. Returns: A string representing the instruction description. """ self._num_placeholders = num_placeholders if self._num_placeholders is None or self._num_placeholders < 0: self._num_placeholders = random.randint(1, _NUM_PLACEHOLDERS) self._description_pattern = ( "The response must contain at least {num_placeholders} placeholders " + "represented by square brackets, such as [address]." ) return self._description_pattern.format(num_placeholders=self._num_placeholders) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return {"num_placeholders": self._num_placeholders} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["num_placeholders"] def check_following(self, value): """Check if the number of placeholders follows the instruction. Args: value: A string representing the response. Returns: True if the actual number of placeholders in the response is greater than or equal to `num_placeholders`; otherwise, False. """ placeholders = re.findall(r"\[.*?\]", value) num_placeholders = len(placeholders) return num_placeholders >= self._num_placeholders class BulletListChecker(Instruction): """Checks the bullet list in the prompt.""" def build_description(self, *, num_bullets=None): """Build the instruction description. Args: num_bullets: An integer specifying the exact number of bullet lists that is required to appear in the response. Returns: A string representing the instruction description. """ self._num_bullets = num_bullets if self._num_bullets is None or self._num_bullets < 0: self._num_bullets = random.randint(1, _NUM_BULLETS) self._description_pattern = ( "Your answer must contain exactly {num_bullets} bullet points. " + "Use the markdown bullet points such as:\n" + "* This is point 1. \n" + "* This is point 2" ) return self._description_pattern.format(num_bullets=self._num_bullets) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return {"num_bullets": self._num_bullets} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["num_bullets"] def check_following(self, value): r"""Check if the number of bullet lists meets the requirement. Args: value: A string representing the response. The response is expected to contain some bullet lists that start with `\*`. Returns: True if the actual number of bullet lists in the response meets the requirement. """ bullet_lists = re.findall(r"^\s*\*[^\*].*$", value, flags=re.MULTILINE) bullet_lists_2 = re.findall(r"^\s*-.*$", value, flags=re.MULTILINE) num_bullet_lists = len(bullet_lists) + len(bullet_lists_2) return num_bullet_lists == self._num_bullets class ConstrainedResponseChecker(Instruction): """Checks the constrained response.""" def build_description(self): """Build the instruction description.""" # A sequence of string(s) representing the options of the expected response. self._constrained_responses = _CONSTRAINED_RESPONSE_OPTIONS self._description_pattern = ( "Answer with one of the following options: {response_options}" ) return self._description_pattern.format( response_options=self._constrained_responses ) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return None def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return [] def check_following(self, value): """Checks if the response matches the constrained options. Args: value: A string representing the response. Returns: True if the actual response contains one of the options in the constrained responses; otherwise False. """ value = value.strip() for constrained_response in self._constrained_responses: if constrained_response in value: return True return False class ConstrainedStartChecker(Instruction): """Checks the response start.""" def build_description(self, *, starter=None): """Build the instruction description. Args: starter: A string representing the keyward that the response should start with. Returns: A string representing the instruction description. """ self._starter = starter.strip() if isinstance(starter, str) else starter if self._starter is None: self._starter = random.choice(_STARTER_OPTIONS) self._description_pattern = ( "During the conversation, when it is your turn, " + "please always start with {starter}" ) return self._description_pattern.format(starter=self._starter) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return {"starter": self._starter} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["starter"] def check_following(self, value): """Checks if the response starts with the constrained keyword or phrase. Args: value: A string representing the response. Returns: True if the response starts with the given phrase or keyword that is contained in `instruction_args`; otherwise, False. """ response_pattern = r"^\s*" + self._starter + r".*$" response_with_constrained_start = re.search( response_pattern, value, flags=re.MULTILINE ) return True if response_with_constrained_start else False class HighlightSectionChecker(Instruction): """Checks the highlighted section.""" def build_description(self, *, num_highlights=None): """Build the instruction description. Args: num_highlights: An integer specifying the minimum number of highlighted sections. Returns: A string representing the instruction description. """ self._num_highlights = num_highlights if self._num_highlights is None or self._num_highlights < 0: self._num_highlights = random.randint(1, _NUM_HIGHLIGHTED_SECTIONS) self._description_pattern = ( "Highlight at least {num_highlights} sections in your answer with " + "markdown, i.e. *highlighted section*." ) return self._description_pattern.format(num_highlights=self._num_highlights) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return {"num_highlights": self._num_highlights} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["num_highlights"] def check_following(self, value): """Checks if the number of highlighted sections meets the requirement. Args: value: a string repesenting the response. The response is expected to contain highlighted sections in the format of *highlighted*. Returns: True if the actual number of highlighted sections in the format of *highlighed sections* meets the minimum requirement; otherwise False. """ num_highlights = 0 highlights = re.findall(r"\*[^\n\*]*\*", value) double_highlights = re.findall(r"\*\*[^\n\*]*\*\*", value) for highlight in highlights: if highlight.strip("*").strip(): num_highlights += 1 for highlight in double_highlights: if highlight.removeprefix("**").removesuffix("**").strip(): num_highlights += 1 return num_highlights >= self._num_highlights class SectionChecker(Instruction): """Checks the sections.""" def build_description(self, *, section_spliter=None, num_sections=None): """Build the instruction description. Args: section_spliter: A string represents the section spliter keyword that marks a new section, i.e., `Section` or `SECTION`. num_sections: An integer specifying the number of sections. Returns: A string representing the instruction description. """ self._section_spliter = ( section_spliter.strip() if isinstance(section_spliter, str) else section_spliter ) if self._section_spliter is None: self._section_spliter = random.choice(_SECTION_SPLITER) self._num_sections = num_sections if self._num_sections is None or self._num_sections < 0: self._num_sections = random.randint(1, _NUM_SECTIONS) self._description_pattern = ( "Your response must have {num_sections} sections. Mark the beginning " + "of each section with {section_spliter} X, such as:\n" + "{section_spliter} 1\n" + "[content of section 1]\n" + "{section_spliter} 2\n" + "[content of section 2]" ) return self._description_pattern.format( num_sections=self._num_sections, section_spliter=self._section_spliter ) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return { "section_spliter": self._section_spliter, "num_sections": self._num_sections, } def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["section_spliter", "num_sections"] def check_following(self, value): """Checks the response contains multiple sections. Args: value: A string representing the response. The response is expected to contain multiple sections (number of sections is greater than 1). A new section starts with `Section 1`, where the number denotes the section index. Returns: True if the number of sections in the response is greater than or equal to the minimum number of sections; otherwise, False. """ section_splitter_patten = r"\s?" + self._section_spliter + r"\s?\d+\s?" sections = re.split(section_splitter_patten, value) num_sections = len(sections) - 1 return num_sections >= self._num_sections class ParagraphChecker(Instruction): """Checks the paragraphs.""" def build_description(self, *, num_paragraphs=None): """Build the instruction description. Args: num_paragraphs: An integer specifying the number of paragraphs. Returns: A string representing the instruction description. """ self._num_paragraphs = num_paragraphs if self._num_paragraphs is None or self._num_paragraphs < 0: self._num_paragraphs = random.randint(1, _NUM_PARAGRAPHS) self._description_pattern = ( "There should be {num_paragraphs} paragraphs. " + "Paragraphs are separated with the markdown divider: ***" ) return self._description_pattern.format(num_paragraphs=self._num_paragraphs) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return {"num_paragraphs": self._num_paragraphs} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["num_paragraphs"] def check_following(self, value): """Checks the response contains required number of paragraphs. Args: value: A string representing the response. The response may contain paragraphs that are separated by the markdown divider: `***`. Returns: True if the actual number of paragraphs is the same as required; otherwise, False. """ paragraphs = re.split(r"\s?\*\*\*\s?", value) num_paragraphs = len(paragraphs) for index, paragraph in enumerate(paragraphs): if not paragraph.strip(): if index == 0 or index == len(paragraphs) - 1: num_paragraphs -= 1 else: return False return num_paragraphs == self._num_paragraphs class PostscriptChecker(Instruction): """Checks the postscript.""" def build_description(self, *, postscript_marker=None): """Build the instruction description. Args: postscript_marker: A string containing the keyword that marks the start of the postscript section. Returns: A string representing the instruction description. """ self._postscript_marker = ( postscript_marker.strip() if isinstance(postscript_marker, str) else postscript_marker ) if self._postscript_marker is None: self._postscript_marker = random.choice(_POSTSCRIPT_MARKER) self._description_pattern = ( "At the end of your response, please explicitly add a postscript " + "starting with {postscript}" ) return self._description_pattern.format(postscript=self._postscript_marker) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return {"postscript_marker": self._postscript_marker} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["postscript_marker"] def check_following(self, value): """Checks if the response follows the postscript format. Args: value: a string representing the response. The response is expected to contain a postscript section. Returns: True if the response contains a postscript section starting with the keyword containing in the `instruction_args`; otherwise False. """ value = value.lower() if self._postscript_marker == "P.P.S": postscript_pattern = r"\s*p\.\s?p\.\s?s.*$" elif self._postscript_marker == "P.S.": postscript_pattern = r"\s*p\.\s?s\..*$" else: postscript_pattern = r"\s*" + self._postscript_marker.lower() + r".*$" postscript = re.findall(postscript_pattern, value, flags=re.MULTILINE) return True if postscript else False class RephraseChecker(Instruction): """Checks the repharse.""" def build_description(self, *, original_message): """Build the instruction description. Args: original_message: A string representing the original message. The rephrased response should only change its words/sentences in between its two asterisks, for example, *change me*. Both original and rephrased messages should contain the changes in the form of *change me*. Returns: A string representing the instruction description. """ if not self.is_change(original_message): raise ValueError( f"Message {original_message} does not contain changes " "in the form of *change me*." ) self._reference_without_change = original_message self._description = ( "Rephrasing: Your rephrased response should only" + "change the words/sentences in between two asterisks" + "such as *change me*." ) return self._description def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return {"original_message": self._reference_without_change} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["original_message"] def check_following(self, value): r"""Checks if the rephrasing follows the instruction. Args: value: A string representing the response, which is expected to rephras the string of `instruction_args`. Returns: True if `value` and `instruction_args` only differ by the words/sentences in between two asterisks such as *change me*; otherwise, False. """ if not self.is_change(value): raise ValueError( f"value {value} does not contain " "changes in the form of *change me*." ) response_without_changes = self.strip_changes(value) reference_without_changes = self.strip_changes(self._reference_without_change) return response_without_changes == reference_without_changes def is_change(self, response): """Check if there is change in the response in the form of *change me*.""" return re.search(r"\*.*\*", response) def strip_changes(self, response): """Strips off the changes.""" return re.sub(r"\*.*\*", "", response) class KeywordChecker(Instruction): """Check the exisitence of certain keywords.""" def build_description(self, *, keywords=None): """Build the instruction description. Args: keywords: A sequence of strings representing the keywords that are expected in the response. Returns: A string representing the instruction description. """ if not keywords: self._keywords = instructions_util.generate_keywords( num_keywords=_NUM_KEYWORDS ) else: self._keywords = keywords self._keywords = sorted(self._keywords) self._description_pattern = "Include keywords {keywords} in the response." return self._description_pattern.format(keywords=self._keywords) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return {"keywords": self._keywords} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["keywords"] def check_following(self, value): """Check if the response contain the expected keywords.""" for keyword in self._keywords: if not re.search(keyword, value, flags=re.IGNORECASE): return False return True class KeywordFrequencyChecker(Instruction): """Check the keyword frequency.""" def build_description(self, *, keyword=None, frequency=None, relation=None): """Build the instruction description. Args: keyword: A string representing a keyword that is expected in the response. frequency: An integer specifying the number of times `keyword` is expected to appear in the response. relation: A string in (`less than`, `at least`), defining the relational operator for comparison. Two relational comparisons are supported for now: if 'less than', the actual number of occurrences < frequency; if 'at least', the actual number of occurrences >= frequency. Returns: A string representing the instruction description. """ if not keyword: self._keyword = instructions_util.generate_keywords(num_keywords=1)[0] else: self._keyword = keyword.strip() self._frequency = frequency if self._frequency is None or self._frequency < 0: self._frequency = random.randint(1, _KEYWORD_FREQUENCY) if relation is None: self._comparison_relation = random.choice(_COMPARISON_RELATION) elif relation not in _COMPARISON_RELATION: raise ValueError( "The supported relation for comparison must be in " f"{_COMPARISON_RELATION}, but {relation} is given." ) else: self._comparison_relation = relation self._description_pattern = ( "In your response, the word {keyword} should appear {relation} " + "{frequency} times." ) return self._description_pattern.format( keyword=self._keyword, relation=self._comparison_relation, frequency=self._frequency, ) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return { "keyword": self._keyword, "frequency": self._frequency, "relation": self._comparison_relation, } def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["keyword", "frequency", "relation"] def check_following(self, value): """Checks if the response contain the keyword with required frequency.""" actual_occurrences = len(re.findall(self._keyword, value, flags=re.IGNORECASE)) if self._comparison_relation == _COMPARISON_RELATION[0]: return actual_occurrences < self._frequency elif self._comparison_relation == _COMPARISON_RELATION[1]: return actual_occurrences >= self._frequency class NumberOfWords(Instruction): """Checks the number of words.""" def build_description(self, *, num_words=None, relation=None): """Build the instruction description. Args: num_words: An integer specifying the number of words contained in the response. relation: A string in (`less than`, `at least`), defining the relational operator for comparison. Two relational comparisons are supported for now: if 'less than', the actual number of words < num_words; if 'at least', the actual number of words >= num_words. Returns: A string representing the instruction description. """ self._num_words = num_words if self._num_words is None or self._num_words < 0: self._num_words = random.randint( _NUM_WORDS_LOWER_LIMIT, _NUM_WORDS_UPPER_LIMIT ) if relation is None: self._comparison_relation = random.choice(_COMPARISON_RELATION) elif relation not in _COMPARISON_RELATION: raise ValueError( "The supported relation for comparison must be in " f"{_COMPARISON_RELATION}, but {relation} is given." ) else: self._comparison_relation = relation self._description_pattern = "Answer with {relation} {num_words} words." return self._description_pattern.format( relation=self._comparison_relation, num_words=self._num_words ) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return {"num_words": self._num_words, "relation": self._comparison_relation} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["num_words", "relation"] def check_following(self, value): """Checks if the response contains the expected number of words.""" num_words = instructions_util.count_words(value) if self._comparison_relation == _COMPARISON_RELATION[0]: return num_words < self._num_words elif self._comparison_relation == _COMPARISON_RELATION[1]: return num_words >= self._num_words class JsonFormat(Instruction): """Check the Json format.""" def build_description(self): self._description_pattern = ( "Entire output should be wrapped in JSON format. You can use markdown" " ticks such as ```." ) return self._description_pattern def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return None def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return [] def check_following(self, value): value = ( value.strip() .removeprefix("```json") .removeprefix("```Json") .removeprefix("```JSON") .removeprefix("```") .removesuffix("```") .strip() ) try: json.loads(value) except ValueError as _: return False return True class ParagraphFirstWordCheck(Instruction): """Check the paragraph and the first word of the nth paragraph.""" def build_description( self, num_paragraphs=None, nth_paragraph=None, first_word=None ): r"""Build the instruction description. Args: num_paragraphs: An integer indicating the number of paragraphs expected in the response. A paragraph is a subset of the string that is expected to be separated by '\n\n'. nth_paragraph: An integer indicating the paragraph number that we look at. Note that n starts from 1. first_word: A string that represent the first word of the bth paragraph. Returns: A string representing the instruction description. """ self._num_paragraphs = num_paragraphs if self._num_paragraphs is None or self._num_paragraphs < 0: self._num_paragraphs = random.randint(1, _NUM_PARAGRAPHS) self._nth_paragraph = nth_paragraph if ( self._nth_paragraph is None or self._nth_paragraph <= 0 or self._nth_paragraph > self._num_paragraphs ): self._nth_paragraph = random.randint(1, self._num_paragraphs + 1) self._first_word = first_word if self._first_word is None: self._first_word = instructions_util.generate_keywords(num_keywords=1)[0] self._first_word = self._first_word.lower() self._description_pattern = ( "There should be {num_paragraphs} paragraphs. " + "Paragraphs and only paragraphs are separated with each other by two " + "new lines as if it was '\\n\\n' in python. " + "Paragraph {nth_paragraph} must start with word {first_word}." ) return self._description_pattern.format( num_paragraphs=self._num_paragraphs, nth_paragraph=self._nth_paragraph, first_word=self._first_word, ) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return { "num_paragraphs": self._num_paragraphs, "nth_paragraph": self._nth_paragraph, "first_word": self._first_word, } def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["num_paragraphs", "nth_paragraph", "first_word"] def check_following(self, value): """Checks for required number of paragraphs and correct first word. Args: value: a string representing the response. The response may contain paragraphs that are separated by two new lines and the first word of the nth paragraph will have to match a specified word. Returns: True if the number of paragraphs is the same as required and the first word of the specified paragraph is the same as required. Otherwise, false. """ paragraphs = re.split(r"\n\n", value) num_paragraphs = len(paragraphs) for paragraph in paragraphs: if not paragraph.strip(): num_paragraphs -= 1 # check that index doesn't go out of bounds if self._nth_paragraph <= num_paragraphs: paragraph = paragraphs[self._nth_paragraph - 1].strip() if not paragraph: return False else: return False first_word = "" punctuation = {".", ",", "?", "!", "'", '"'} # get first word and remove punctuation word = paragraph.split()[0].strip() # TODO(jeffrey): make more complex? word = word.lstrip("'") word = word.lstrip('"') for letter in word: if letter in punctuation: break first_word += letter.lower() return num_paragraphs == self._num_paragraphs and first_word == self._first_word # TODO(jeffrey) add relation - at least/at most? class KeySentenceChecker(Instruction): """Check the existence of certain key sentences.""" def build_description(self, key_sentences=None, num_sentences=None): """Build the instruction description. Args: key_sentences: A sequences of strings representing the key sentences that are expected in the response. num_sentences: The number of key sentences that are expected to be seen in the response. Returns: A string representing the instruction description. """ if not key_sentences: # TODO(jeffrey) make a generate sentences function? wonderwords package self._key_sentences = set(["For now, this is fine."]) else: self._key_sentences = key_sentences if not num_sentences: self._num_sentences = random.randint(1, len(self._key_sentences)) else: self._num_sentences = num_sentences self._description_pattern = ( "Include {num_sentences} of the following sentences {key_sentences}" ) return self._description_pattern.format( num_sentences=self._num_sentences, key_sentences=self._key_sentences ) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return { "num_sentences": self._num_sentences, "key_sentences": list(self._key_sentences), } def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["num_sentences", "key_sentences"] def check_following(self, value): """Checks if the response contains the expected key sentences.""" count = 0 sentences = instructions_util.split_into_sentences(value) for sentence in self._key_sentences: if sentence in sentences: count += 1 return count == self._num_sentences class ForbiddenWords(Instruction): """Checks that specified words are not used in response.""" def build_description(self, forbidden_words=None): """Build the instruction description. Args: forbidden_words: A sequences of strings respresenting words that are not allowed in the response. Returns: A string representing the instruction description. """ if not forbidden_words: self._forbidden_words = instructions_util.generate_keywords( num_keywords=_NUM_KEYWORDS ) else: self._forbidden_words = list(set(forbidden_words)) self._forbidden_words = sorted(self._forbidden_words) self._description_pattern = ( "Do not include keywords {forbidden_words} in the response." ) return self._description_pattern.format(forbidden_words=self._forbidden_words) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return {"forbidden_words": self._forbidden_words} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["forbidden_words"] def check_following(self, value): """Check if the response does not contain the expected keywords.""" for word in self._forbidden_words: if re.search(r"\b" + word + r"\b", value, flags=re.IGNORECASE): return False return True class RephraseParagraph(Instruction): """Checks that the paragraph is rephrased.""" def build_description(self, *, original_paragraph, low, high): """Builds the instruction description. Args: original_paragraph: A string presenting the original paragraph. The rephrases response should have betweeb low-high words in common. low: An integer presenting the lower bound of similar words. high: An integer representing the upper bound of similar words. Returns: A string representing the instruction description. """ # TODO(jeffrey) make more encompassing self._original_paragraph = original_paragraph self._low = low self._high = high self._description = ( "Rephrase the following paragraph: " + "{original_paragraph}\nYour response should have " + "between {low} and {high} of the same words. " + "Words are the same if and only if all of the " + "letters, ignoring cases, are the same. For " + "example, 'run' is the same as 'Run' but different " + "to 'ran'." ) return self._description.format( original_paragraph=original_paragraph, low=self._low, high=self._high ) def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return { "original_paragraph": self._original_paragraph, "low": self._low, "high": self._high, } def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["original_paragraph", "low", "high"] def check_following(self, value): val_words = re.findall(r"\w+", value.lower()) original_words = re.findall(r"\w+", self._original_paragraph.lower()) similar_words = 0 dict_val = collections.Counter(val_words) dict_original = collections.Counter(original_words) for word in dict_original: similar_words += min(dict_original[word], dict_val[word]) return similar_words >= self._low and similar_words <= self._high class TwoResponsesChecker(Instruction): """Check that two responses were given.""" def build_description(self): """Build the instruction description.""" self._description_pattern = ( "Give two different responses. Responses and only responses should" " be separated by 6 asterisk symbols: ******." ) return self._description_pattern def get_instruction_args(self): """Returns the keyward args of `build_description`.""" return None def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return [] def check_following(self, value): """Checks if the response has two different answers. Args: value: A string representing the response. Returns: True if two responses are detected and false otherwise. """ valid_responses = list() responses = value.split("******") for index, response in enumerate(responses): if not response.strip(): if index != 0 and index != len(responses) - 1: return False else: valid_responses.append(response) return ( len(valid_responses) == 2 and valid_responses[0].strip() != valid_responses[1].strip() ) class RepeatPromptThenAnswer(Instruction): """Checks that Prompt is first repeated then answered.""" def build_description(self, *, prompt_to_repeat=None): """Build the instruction description. Args: prompt_to_repeat: The prompt that is meant to be repeated. Returns: A string representing the instruction description. """ if not prompt_to_repeat: raise ValueError("prompt_to_repeat must be set.") else: self._prompt_to_repeat = prompt_to_repeat self._description_pattern = ( "First repeat the request word for word without change," " then give your answer (1. do not say any words or characters" " before repeating the request; 2. the request you need to repeat" " does not include this sentence)" ) return self._description_pattern def get_instruction_args(self): return {"prompt_to_repeat": self._prompt_to_repeat} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["prompt_to_repeat"] def check_following(self, value): if value.strip().lower().startswith(self._prompt_to_repeat.strip().lower()): return True return False class EndChecker(Instruction): """Checks that the prompt ends with a given phrase.""" def build_description(self, *, end_phrase=None): """Build the instruction description. Args: end_phrase: A string representing the phrase the response should end with. Returns: A string representing the instruction description. """ self._end_phrase = ( end_phrase.strip() if isinstance(end_phrase, str) else end_phrase ) if self._end_phrase is None: self._end_phrase = random.choice(_ENDING_OPTIONS) self._description_pattern = ( "Finish your response with this exact phrase {ender}. " "No other words should follow this phrase." ) return self._description_pattern.format(ender=self._end_phrase) def get_instruction_args(self): return {"end_phrase": self._end_phrase} def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["end_phrase"] def check_following(self, value): """Checks if the response ends with the expected phrase.""" value = value.strip().strip('"').lower() self._end_phrase = self._end_phrase.strip().lower() return value.endswith(self._end_phrase) class TitleChecker(Instruction): """Checks the response for a title.""" def build_description(self): """Build the instruction description.""" self._description_pattern = ( "Your answer must contain a title, wrapped in double angular brackets," " such as <>." ) return self._description_pattern def get_instruction_args(self): return None def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return [] def check_following(self, value): """Checks if the response contains a title.""" pattern = r"<<[^\n]+>>" re_pattern = re.compile(pattern) titles = re.findall(re_pattern, value) for title in titles: if title.lstrip("<").rstrip(">").strip(): return True return False class LetterFrequencyChecker(Instruction): """Checks letter frequency.""" def build_description(self, *, letter=None, let_frequency=None, let_relation=None): """Build the instruction description. Args: letter: A string representing a letter that is expected in the response. let_frequency: An integer specifying the number of times `keyword` is expected to appear in the response. let_relation: A string in (`less than`, `at least`), defining the relational operator for comparison. Two relational comparisons are supported for now; if 'less than', the actual number of occurrences < frequency; if 'at least', the actual number of occurrences >= frequency. Returns: A string representing the instruction description. """ if ( not letter or len(letter) > 1 or ord(letter.lower()) < 97 or ord(letter.lower()) > 122 ): self._letter = random.choice(list(string.ascii_letters)) else: self._letter = letter.strip() self._letter = self._letter.lower() self._frequency = let_frequency if self._frequency is None or self._frequency < 0: self._frequency = random.randint(1, _LETTER_FREQUENCY) if let_relation is None: self._comparison_relation = random.choice(_COMPARISON_RELATION) elif let_relation not in _COMPARISON_RELATION: raise ValueError( "The supported relation for comparison must be in " f"{_COMPARISON_RELATION}, but {let_relation} is given." ) else: self._comparison_relation = let_relation self._description_pattern = ( "In your response, the letter {letter} should appear {let_relation}" " {let_frequency} times." ) return self._description_pattern.format( letter=self._letter, let_frequency=self._frequency, let_relation=self._comparison_relation, ) def get_instruction_args(self): """Returns the keyword args of build description.""" return { "letter": self._letter, "let_frequency": self._frequency, "let_relation": self._comparison_relation, } def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["letter", "let_frequency", "let_relation"] def check_following(self, value): """Checks that the response contains the letter at the right frequency.""" value = value.lower() letters = collections.Counter(value) if self._comparison_relation == _COMPARISON_RELATION[0]: return letters[self._letter] < self._frequency else: return letters[self._letter] >= self._frequency class CapitalLettersEnglishChecker(Instruction): """Checks that the response is in english and is in all capital letters.""" def build_description(self): """Build the instruction description.""" self._description_pattern = ( "Your entire response should be in English, and in all capital letters." ) return self._description_pattern def get_instruction_args(self): return None def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return [] def check_following(self, value): """Checks that the response is in English and in all capital letters.""" assert isinstance(value, str) try: return value.isupper() and langdetect.detect(value) == "en" except langdetect.LangDetectException as e: # Count as instruction is followed. logging.error( "Unable to detect language for text %s due to %s", value, e ) # refex: disable=pytotw.037 return True class LowercaseLettersEnglishChecker(Instruction): """Checks that the response is in english and is in all lowercase letters.""" def build_description(self): """Build the instruction description.""" self._description_pattern = ( "Your entire response should be in English, and in all lowercase" " letters. No capital letters are allowed." ) return self._description_pattern def get_instruction_args(self): return None def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return [] def check_following(self, value): """Checks that the response is in English and in all lowercase letters.""" assert isinstance(value, str) try: return value.islower() and langdetect.detect(value) == "en" except langdetect.LangDetectException as e: # Count as instruction is followed. logging.error( "Unable to detect language for text %s due to %s", value, e ) # refex: disable=pytotw.037 return True class CommaChecker(Instruction): """Checks the response for no commas.""" def build_description(self): """Build the instruction description.""" self._description_pattern = ( "In your entire response, refrain from the use of any commas." ) return self._description_pattern def get_instruction_args(self): return None def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return [] def check_following(self, value): """Checks that the response does not contain commas.""" return not re.search(r"\,", value) class CapitalWordFrequencyChecker(Instruction): """Checks frequency of words with all capital letters.""" def build_description( self, capital_frequency=None, capital_relation=None, ): """Build the instruction description. Args: capital_frequency: An integer that represents the number of words that should be in all capital letters. capital_relation: A string that is 'at least' or 'at most' that refers to the frequency. Returns: A string representing the instruction description. """ self._frequency = capital_frequency if self._frequency is None: self._frequency = random.randint(1, _ALL_CAPITAL_WORD_FREQUENCY) self._comparison_relation = capital_relation if capital_relation is None: self._comparison_relation = random.choice(_COMPARISON_RELATION) elif capital_relation not in _COMPARISON_RELATION: raise ValueError( "The supported relation for comparison must be in " f"{_COMPARISON_RELATION}, but {capital_relation} is given." ) self._description_pattern = ( "In your response, words with all capital letters should appear" " {relation} {frequency} times." ) return self._description_pattern.format( frequency=self._frequency, relation=self._comparison_relation ) def get_instruction_args(self): """Returns the keyword args of build description.""" return { "capital_frequency": self._frequency, "capital_relation": self._comparison_relation, } def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return ["capital_frequency", "capital_relation"] def check_following(self, value): """Checks the frequency of words with all capital letters.""" # Hyphenated words will count as one word words = instructions_util.nltk.word_tokenize(value) capital_words = [word for word in words if word.isupper()] capital_words = len(capital_words) if self._comparison_relation == _COMPARISON_RELATION[0]: return capital_words < self._frequency else: return capital_words >= self._frequency class QuotationChecker(Instruction): """Checks response is wrapped with double quotation marks.""" def build_description(self): """Build the instruction description.""" self._description_pattern = ( "Wrap your entire response with double quotation marks." ) return self._description_pattern def get_instruction_args(self): """Returns the keyword args of build description.""" return None def get_instruction_args_keys(self): """Returns the args keys of `build_description`.""" return [] def check_following(self, value): """Checks if the response is wrapped with double quotation marks.""" value = value.strip() return len(value) > 1 and value[0] == '"' and value[-1] == '"'