from banjo.models import Model, StringField, IntegerField from fuzzywuzzy import fuzz class Riddle(Model): question = StringField() answer = StringField() guesses = IntegerField() correct = IntegerField() MIN_FUZZ_RATIO = 80 def __repr__(self): """Declares how to represent a Riddle as a string. A riddle's string will look something like this: """ return "".format( self.id or '(unsaved)', self.question, self.correct, self.guesses ) def is_valid(self): "Checks whether this riddle is valid. In other words, when validate() finds no errors." return len(self.validate()) == 0 def validate(self): "Checks whether this riddle can be saved" errors = [] if self.question is None: errors.append("question is required") if self.answer is None: errors.append("answer is required") return errors def difficulty(self): """Calculates and returns the riddle's difficulty. The difficulty is basically 1 minus the fraction of guesses which were correct. So a Riddle with a difficulty of 1 is impossibly hard, while a Riddle with a difficulty of 0 is easy--everyone gets it right! There is an interesting detail here though. Instead of 1 - correct/guesses, we add 1 to correct and we add 1 to guesses. This is called "smoothing" and it provides two benefits: First, we avoid having an undefined difficulty when there have been no guesses (0/0 would raise a ZeroDivisionError) Second, it gives better values for difficulty when there have been no correct guesses or no incorrect guesses. Consider an impossible riddle: Number of wrong guesses Difficulty with smoothing Difficulty without smoothing 0 0 error 1 0.5 1 2 0.66 1 3 0.75 1 4 0.8 1 100 0.99 1 1000 0.999 1 With smoothing, a Riddle's difficulty can only be really high if there are few correct guesses and a lot of guesses. This seems like the right way to define difficulty. """ return 1 - (self.correct + 1) / (self.guesses + 1) def to_dict(self, with_answer=True): "Returns this Riddle's properties in a dict, optionally including the answer" result = { "id": self.id, "question": self.question, "guesses": self.guesses, "correct": self.correct, "difficulty": self.difficulty(), } if with_answer: result["answer"] = self.answer return result def check_guess(self, guess): """Checks whether a guess is correct and logs the attempt. We don't want to be too strict, so we will accept guesses which are close to the answer. Fuzzy string-matching is an interesting problem, which we will sidestep by using the `fuzzywuzzy` library. `FUZZ_RATIO` is our limit for how similar the answers have to be. Also, we don't care about upper-case and lower-case, so we'll cast everything to lower. For example, consider the riddle, "What's brown and sticky?" The answer, of course, is "A stick" Here are some attempts with their fuzz ratios: - "a stick" 100 - "a stik" 92 - "stick" 83 - "it's a stick" 74 - "idk" 40 """ self.guesses += 1 similarity = fuzz.ratio(guess.lower(), self.answer.lower()) is_correct = similarity >= self.MIN_FUZZ_RATIO if is_correct: self.correct+= 1 self.save() return is_correct