Ejemplo n.º 1
0
    def _init_collections(self):
        uid = self.user.id
        self.dialog_states = DialogStates(
            self._initial_state,
            redis=self._redis,
            key=f'{uid}:ds')

        # User and Bot utterances from newest to oldest
        if self._redis:
            self._utterances = SyncableDeque(
                maxlen=self.SIZE_LIMIT,
                redis=self._redis,
                key=f'{uid}:utterances')  # type: Deque[Union[MessageUnderstanding, ChatAction]]
            self._value_store = SyncableDict(  # type: Dict
                redis=self._redis,
                writeback=True,
                key=f'{uid}:kv_store')
            self._utterances.sync()
            self._value_store.sync()
        else:
            self._utterances = deque()  # type: Deque[Union[MessageUnderstanding, ChatAction]]
            self._value_store = dict()

        self._answered_question_ids = UserAnswers.get_answered_question_ids(self.user)

        self._current_question = None  # type: Question
        self._current_questionnaire = None  # type: Questionnaire

        self._all_done = False  # type: bool
        self._update_question_context()

        # cached property
        self.__last_user_utterance = None  # type: MessageUnderstanding
def test_basic_usage(ds: DialogStates):
    def states():
        return list(ds.iter_states())

    assert states() == [INITIAL_STATE]

    ds.put("test1")
    assert states() == ["test1"]
    assert states() == ["test1"]

    ds.put(("test2A", "test2B"))
    ds.update_step()
    assert states() == [("test2A", "test2B")]

    with pytest.raises(ValueError):
        ds.put(("1", "2", "3", -1))  # negative lifetime
Ejemplo n.º 3
0
 def record_dialog(self, update: Update, actions: List[ChatAction],
                   dialog_states: DialogStates):
     uid = update.user.id
     start_time = self.conversation_starts.setdefault(uid, update.datetime)
     entry = CommentedMap(  # ensures ordered key-value pairs in YAML
         time=int((update.datetime - start_time).total_seconds()),
         user_says=update.message_text,
         intent=update.understanding.intent,
         parameters=update.understanding.parameters,
         new_states=[str(x) for x in list(dialog_states.iter_states())],
         responses=list(
             itertools.chain.from_iterable([a.intents for a in actions])))
     self.conversations.setdefault(uid, []).append(entry)
     self._save(update.user, schedule_publish=True)
     self._check_publish_trigger(update, actions, dialog_states)
Ejemplo n.º 4
0
class Context(collections.MutableMapping):
    """
    Stores machine-understandable data about the recent conversation context.

    This includes the current state of the dialog, incoming `MessageUnderstandings` and outgoing `ChatActions`,
    the questions a user has answered so far (`_answered_question_ids`), as well as a way to calculate the next
    question applicable for a user (done automatically when new utterances are added).

    Acts like a dictionary with methods `_set_value` and `_get_value` to provide a persistent, random access data
    storage for a particular user.
    """

    # Maximum number of utterances a single context keeps stored.
    SIZE_LIMIT = 50

    def __init__(self, user: User, initial_state, redis=None):
        self.user = user

        self._redis = redis
        self._initial_state = initial_state

        self._init_collections()

        # Utterances are synced with the redis database every couple of seconds, as opposed to immediately.
        self._sync_timeout = 5  # seconds
        self._scheduler = sched.scheduler(time.time, time.sleep)
        self.__sync_utt_job = None  # type: sched.Event
        self.__utt_sync_lock = threading.Lock()

        self.__name__ = "Context"

    def _init_collections(self):
        uid = self.user.id
        self.dialog_states = DialogStates(
            self._initial_state,
            redis=self._redis,
            key=f'{uid}:ds')

        # User and Bot utterances from newest to oldest
        if self._redis:
            self._utterances = SyncableDeque(
                maxlen=self.SIZE_LIMIT,
                redis=self._redis,
                key=f'{uid}:utterances')  # type: Deque[Union[MessageUnderstanding, ChatAction]]
            self._value_store = SyncableDict(  # type: Dict
                redis=self._redis,
                writeback=True,
                key=f'{uid}:kv_store')
            self._utterances.sync()
            self._value_store.sync()
        else:
            self._utterances = deque()  # type: Deque[Union[MessageUnderstanding, ChatAction]]
            self._value_store = dict()

        self._answered_question_ids = UserAnswers.get_answered_question_ids(self.user)

        self._current_question = None  # type: Question
        self._current_questionnaire = None  # type: Questionnaire

        self._all_done = False  # type: bool
        self._update_question_context()

        # cached property
        self.__last_user_utterance = None  # type: MessageUnderstanding

    def _sched_sync_utterance(self):
        if not isinstance(self._utterances, SyncableDeque):
            return
        try:
            self._scheduler.cancel(self.__sync_utt_job)
        except ValueError:
            pass
        self.__sync_utt_job = self._scheduler.enter(self._sync_timeout, 1, self._sync_utterances)
        threading.Thread(target=self._scheduler.run).start()

    def _sync_utterances(self):
        with self.__utt_sync_lock:
            self._utterances.sync()

    def add_user_utterance(self, understanding: MessageUnderstanding):
        with self.__utt_sync_lock:
            self._utterances.appendleft(understanding)
        self.__last_user_utterance = understanding
        self._sched_sync_utterance()

    def add_actions(self, actions: List[ChatAction]):
        for action in actions:
            with self.__utt_sync_lock:
                self._utterances.appendleft(action)
        self._sched_sync_utterance()

    @property
    def claim_finished(self):
        return self._all_done

    def _filter_utterances(self, utterance_type, filter_func, age_limit, only_latest):
        """
        Filters all contained utterances by a callable `filter_func` and an `age_limit`.
        :param utterance_type: Either `MessageUnderstanding` or `ChatAction`
        :param filter_func: A function to filter the valid utterances
        :param age_limit: A timedelta or number in seconds
        :param only_latest: Return the most recent utterance only
        :return:
        """
        if isinstance(age_limit, timedelta):
            age_limit = datetime.now() - age_limit

        age = -1
        results = list()
        for utt in self._utterances:  # newest to oldest
            if not isinstance(utt, utterance_type):
                continue

            # skip ChatActions
            age += 1

            if isinstance(age_limit, datetime):
                if utt.date < age_limit:
                    continue
            elif isinstance(age_limit, int):
                if age > age_limit:
                    break
            if filter_func(utt):
                results.append(utt)
                if only_latest:
                    return utt

        return results

    def has_incoming_intent(self,
                            intent: str,
                            age_limit: Union[timedelta, datetime, int] = settings.CONTEXT_LOOKUP_RECENCY,
                            ) -> bool:
        """
        Returns True if there has been an incoming `MessageUnderstanding` with the specified `intent` not older than
        `age_limit`.
        """
        intent = format_intent(intent)
        return bool(self.filter_incoming_utterances(
            lambda understanding: understanding.intent == intent,
            age_limit,
            only_latest=True
        ))

    def has_outgoing_intent(self,
                            intent: str,
                            age_limit: Union[timedelta, datetime, int] = settings.CONTEXT_LOOKUP_RECENCY,
                            ) -> bool:
        """
        Returns True if there has been an outgoing `ChatAction` with the specified `intent` not older than
        `age_limit`.
        """
        intent = format_intent(intent)
        return bool(self.filter_outgoing_utterances(
            lambda action: intent in action.intents,
            age_limit,
            only_latest=True
        ))

    def filter_incoming_utterances(
            self,
            filter_func: Callable[[MessageUnderstanding], bool],
            age_limit: Union[timedelta, datetime, int] = settings.CONTEXT_LOOKUP_RECENCY,
            only_latest: bool = False
    ) -> Union[MessageUnderstanding, List[MessageUnderstanding]]:
        """
        Filters all incoming utterances by a callable `filter_func` not older than `age_limit`.
        :param filter_func: A function to filter the valid utterances
        :param age_limit: A timedelta or number in seconds
        :param only_latest: Whether to return only the most recent utterance
        :return: An object or a list of type `MessageUnderstandings`
        """
        return self._filter_utterances(MessageUnderstanding, filter_func, age_limit, only_latest)

    def filter_outgoing_utterances(
            self,
            filter_func: Callable[[ChatAction], bool],
            age_limit: Union[timedelta, datetime, int] = settings.CONTEXT_LOOKUP_RECENCY,
            only_latest: bool = False
    ) -> Union[ChatAction, List[ChatAction]]:
        """
        Filters all outgoing chat actions by a callable `filter_func` not older than `age_limit`.
        :param filter_func: A function to filter the valid utterances
        :param age_limit: A timedelta or number in seconds
        :param only_latest: Whether to return only the most recent utterance
        :return: An object or a list of type `ChatAction`
        """
        return self._filter_utterances(ChatAction, filter_func, age_limit, only_latest)

    def add_answer_to_question(self, question: Union[Question, str], answer: str):
        """
        Creates a database entry for the answer given by the user to which this context belongs.
        """
        UserAnswers.add_answer(
            user=self.user,
            question_id=question.id if isinstance(question, Question) else question,
            answer=answer,
        )
        self._answered_question_ids.add(question.id if isinstance(question, Question) else question)
        self._update_question_context()

    def _update_question_context(self) -> None:
        """
        Calculates the best next question and questionnaire to ask
        depending on the `self._answered_question_ids`.
        """
        try:
            self._current_questionnaire = next(
                q for q
                in all_questionnaires
                if q.next_question(self._answered_question_ids))
            self._current_question = self._current_questionnaire.next_question(self._answered_question_ids)
            self._all_done = False
        except StopIteration:
            self._all_done = True

    @property
    def has_answered_questions(self):
        return len(self._answered_question_ids) > 0

    @property
    def last_user_utterance(self):
        return self.__last_user_utterance

    @property
    def current_question(self):
        return self._current_question

    @property
    def current_questionnaire(self):
        return self._current_questionnaire

    @property
    def questionnaire_completion_ratio(self):
        """ Returns a ratio of how many questions in the current questionnaire have been answered. """
        if self._all_done:
            return 1.0
        return self._current_questionnaire.completion_ratio(self._answered_question_ids)

    @property
    def overall_completion_ratio(self):
        """ Returns a ratio of how many questions have been answered divided by the total number of questions. """
        if self._all_done:
            return 1.0
        return (all_questionnaires.index(self._current_questionnaire) / len(all_questionnaires)) + (
                self.questionnaire_completion_ratio / len(all_questionnaires))

    def reset_all(self) -> int:
        self.dialog_states.reset()

        self._utterances.clear()
        self._value_store.clear()
        if self._redis:
            self._utterances.sync()
            self._sync_utterances()

        num_reset = UserAnswers.reset_answers(self.user)
        self._answered_question_ids = set()
        self._update_question_context()
        # self.__last_user_utterance = None  # type: MessageUnderstanding
        return num_reset

    def __setitem__(self, key, value):
        self._value_store[key] = value
        if isinstance(self._value_store, SyncableDict):
            self._value_store.sync()
        return value

    def __delitem__(self, key):
        del self._value_store[key]

    def __getitem__(self, key):
        return self._value_store[key]

    def __len__(self):
        return len(self._value_store)

    def __iter__(self):
        return iter(self._value_store)
def ds():
    return DialogStates(INITIAL_STATE)
def test_lifetime(ds: DialogStates):
    def states():
        return list(ds.iter_states())

    ds.put(("test0", 1))
    assert states() == ["test0", INITIAL_STATE]
    ds.update_step()
    assert states() == ["test0", INITIAL_STATE]
    ds.update_step()
    assert states() == [INITIAL_STATE]

    ds.put(("test1", 2))
    for _ in range(3):
        assert states() == ["test1", INITIAL_STATE]
        ds.update_step()
    assert states() == [INITIAL_STATE]

    ds.put((("test1A", "test1B"), 4))
    assert states() == [("test1A", "test1B"), INITIAL_STATE]
    ds.update_step()
    ds.put(("test2", 2))
    for _ in range(3):
        assert states() == ["test2", ("test1A", "test1B"), INITIAL_STATE]
        ds.update_step()
    assert states() == [("test1A", "test1B"), INITIAL_STATE]
    ds.update_step()
    assert states() == [INITIAL_STATE]
Ejemplo n.º 7
0
def get_state_machine():
    return DialogStates(States.SMALLTALK)