-
-
Notifications
You must be signed in to change notification settings - Fork 3.8k
/
state_domain.py
4892 lines (4269 loc) · 202 KB
/
state_domain.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# coding: utf-8
#
# Copyright 2018 The Oppia Authors. All Rights Reserved.
#
# 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.
"""Domain object for states and their constituents."""
from __future__ import annotations
import copy
import itertools
import logging
import math
import re
from core import android_validation_constants
from core import feconf
from core import schema_utils
from core import utils
from core.constants import constants
from core.domain import customization_args_util
from core.domain import param_domain
from core.domain import translation_domain
from extensions import domain
from extensions.objects.models import objects
from typing import (
Any, Callable, Dict, Iterator, List, Literal, Mapping, Optional, Tuple,
Type, TypedDict, TypeVar, Union, cast, overload
)
from core.domain import html_cleaner # pylint: disable=invalid-import-from # isort:skip
from core.domain import interaction_registry # pylint: disable=invalid-import-from # isort:skip
from core.domain import rules_registry # pylint: disable=invalid-import-from # isort:skip
MYPY = False
if MYPY: # pragma: no cover
from extensions.interactions import base
_GenericCustomizationArgType = TypeVar('_GenericCustomizationArgType')
# TODO(#14537): Refactor this file and remove imports marked
# with 'invalid-import-from'.
# The `AllowedRuleSpecInputTypes` is union of allowed types that a
# RuleSpec's inputs dictionary can accept for it's values.
AllowedRuleSpecInputTypes = Union[
str,
int,
float,
List[str],
List[List[str]],
# Here we use type Any because some rule specs have deeply nested types,
# such as for the `NumberWithUnits` interaction.
Mapping[
str, Union[str, List[str], int, bool, float, Dict[str, int], List[Any]]
],
]
class TrainingDataDict(TypedDict):
"""Type for the training data dictionary."""
answer_group_index: int
answers: List[str]
class AnswerGroupDict(TypedDict):
"""Dictionary representing the AnswerGroup object."""
outcome: OutcomeDict
rule_specs: List[RuleSpecDict]
training_data: List[str]
tagged_skill_misconception_id: Optional[str]
class StateVersionHistoryDict(TypedDict):
"""Dictionary representing the StateVersionHistory object."""
previously_edited_in_version: Optional[int]
state_name_in_previous_version: Optional[str]
committer_id: str
AcceptableCorrectAnswerTypes = Union[
List[List[str]], List[str], str, Dict[str, str], int, None
]
class AnswerGroup(translation_domain.BaseTranslatableObject):
"""Value object for an answer group. Answer groups represent a set of rules
dictating whether a shared feedback should be shared with the user. These
rules are ORed together. Answer groups may also support a classifier
that involve soft matching of answers to a set of training data and/or
example answers dictated by the creator.
"""
def __init__(
self,
outcome: Outcome,
rule_specs: List[RuleSpec],
training_data: List[str],
tagged_skill_misconception_id: Optional[str]
) -> None:
"""Initializes a AnswerGroup domain object.
Args:
outcome: Outcome. The outcome corresponding to the answer group.
rule_specs: list(RuleSpec). List of rule specifications.
training_data: list(*). List of answers belonging to training
data of this answer group.
tagged_skill_misconception_id: str or None. The format is
'<skill_id>-<misconception_id>', where skill_id is the skill ID
of the tagged misconception and misconception_id is the id of
the tagged misconception for the answer group. It is not None
only when a state is part of a Question object that
tests a particular skill.
"""
self.rule_specs = [RuleSpec(
rule_spec.rule_type, rule_spec.inputs
) for rule_spec in rule_specs]
self.outcome = outcome
self.training_data = training_data
self.tagged_skill_misconception_id = tagged_skill_misconception_id
def get_translatable_contents_collection(
self, **kwargs: Optional[str]
) -> translation_domain.TranslatableContentsCollection:
"""Get all translatable fields in the answer group.
Returns:
translatable_contents_collection: TranslatableContentsCollection.
An instance of TranslatableContentsCollection class.
"""
translatable_contents_collection = (
translation_domain.TranslatableContentsCollection())
if self.outcome is not None:
(
translatable_contents_collection
.add_fields_from_translatable_object(self.outcome)
)
# TODO(#16256): Instead of hardcoding interactions name here,
# Interaction can have a flag indicating whether the rule_specs can have
# translations.
for rule_spec in self.rule_specs:
if kwargs['interaction_id'] not in ['TextInput', 'SetInput']:
break
(
translatable_contents_collection
.add_fields_from_translatable_object(
rule_spec,
interaction_id=kwargs['interaction_id'])
)
return translatable_contents_collection
def to_dict(self) -> AnswerGroupDict:
"""Returns a dict representing this AnswerGroup domain object.
Returns:
dict. A dict, mapping all fields of AnswerGroup instance.
"""
return {
'rule_specs': [rule_spec.to_dict()
for rule_spec in self.rule_specs],
'outcome': self.outcome.to_dict(),
'training_data': self.training_data,
'tagged_skill_misconception_id': self.tagged_skill_misconception_id
}
# TODO(#16467): Remove `validate` argument after validating all Question
# states by writing a migration and audit job. As the validation for
# answer group is common between Exploration and Question and the Question
# data is not yet migrated, we do not want to call the validations
# while we load the Question.
@classmethod
def from_dict(
cls, answer_group_dict: AnswerGroupDict, validate: bool = True
) -> AnswerGroup:
"""Return a AnswerGroup domain object from a dict.
Args:
answer_group_dict: dict. The dict representation of AnswerGroup
object.
validate: bool. False, when the validations should not be called.
Returns:
AnswerGroup. The corresponding AnswerGroup domain object.
"""
return cls(
Outcome.from_dict(answer_group_dict['outcome'], validate=validate),
[RuleSpec.from_dict(rs)
for rs in answer_group_dict['rule_specs']],
answer_group_dict['training_data'],
answer_group_dict['tagged_skill_misconception_id']
)
def validate(
self,
interaction: base.BaseInteraction,
exp_param_specs_dict: Dict[str, param_domain.ParamSpec],
*,
tagged_skill_misconception_id_required: bool = False,
) -> None:
"""Verifies that all rule classes are valid, and that the AnswerGroup
only has one classifier rule.
Args:
interaction: BaseInteraction. The interaction object.
exp_param_specs_dict: dict. A dict of all parameters used in the
exploration. Keys are parameter names and values are ParamSpec
value objects with an object type property (obj_type).
tagged_skill_misconception_id_required: bool. The 'tagged_skill_
misconception_id' is required or not.
Raises:
ValidationError. One or more attributes of the AnswerGroup are
invalid.
ValidationError. The AnswerGroup contains more than one classifier
rule.
ValidationError. The tagged_skill_misconception_id is not valid.
"""
if not isinstance(self.rule_specs, list):
raise utils.ValidationError(
'Expected answer group rules to be a list, received %s'
% self.rule_specs)
if (
self.tagged_skill_misconception_id is not None and
not tagged_skill_misconception_id_required
):
raise utils.ValidationError(
'Expected tagged skill misconception id to be None, '
'received %s' % self.tagged_skill_misconception_id)
if (
self.tagged_skill_misconception_id is not None and
tagged_skill_misconception_id_required
):
if not isinstance(self.tagged_skill_misconception_id, str):
raise utils.ValidationError(
'Expected tagged skill misconception id to be a str, '
'received %s' % self.tagged_skill_misconception_id)
if not re.match(
constants.VALID_SKILL_MISCONCEPTION_ID_REGEX,
self.tagged_skill_misconception_id):
raise utils.ValidationError(
'Expected the format of tagged skill misconception id '
'to be <skill_id>-<misconception_id>, received %s'
% self.tagged_skill_misconception_id)
if len(self.rule_specs) == 0:
raise utils.ValidationError(
'There must be at least one rule for each answer group.')
for rule_spec in self.rule_specs:
if rule_spec.rule_type not in interaction.rules_dict:
raise utils.ValidationError(
'Unrecognized rule type: %s' % rule_spec.rule_type)
rule_spec.validate(
interaction.get_rule_param_list(rule_spec.rule_type),
exp_param_specs_dict)
self.outcome.validate()
@staticmethod
def convert_html_in_answer_group(
answer_group_dict: AnswerGroupDict,
conversion_fn: Callable[[str], str],
html_field_types_to_rule_specs: Dict[
str, rules_registry.RuleSpecsExtensionDict
]
) -> AnswerGroupDict:
"""Checks for HTML fields in an answer group dict and converts it
according to the conversion function.
Args:
answer_group_dict: dict. The answer group dict.
conversion_fn: function. The function to be used for converting the
HTML.
html_field_types_to_rule_specs: dict. A dictionary that specifies
the locations of html fields in rule specs. It is defined as a
mapping of rule input types to a dictionary containing
interaction id, format, and rule types. See
html_field_types_to_rule_specs_state_v41.json for an example.
Returns:
dict. The converted answer group dict.
"""
answer_group_dict['outcome']['feedback']['html'] = conversion_fn(
answer_group_dict['outcome']['feedback']['html'])
for rule_spec_index, rule_spec in enumerate(
answer_group_dict['rule_specs']):
answer_group_dict['rule_specs'][rule_spec_index] = (
RuleSpec.convert_html_in_rule_spec(
rule_spec, conversion_fn, html_field_types_to_rule_specs))
return answer_group_dict
class HintDict(TypedDict):
"""Dictionary representing the Hint object."""
hint_content: SubtitledHtmlDict
class Hint(translation_domain.BaseTranslatableObject):
"""Value object representing a hint."""
def __init__(
self,
hint_content: SubtitledHtml
) -> None:
"""Constructs a Hint domain object.
Args:
hint_content: SubtitledHtml. The hint text and ID referring to the
other assets for this content.
"""
self.hint_content = hint_content
def get_translatable_contents_collection(
self,
**kwargs: Optional[str]
) -> translation_domain.TranslatableContentsCollection:
"""Get all translatable fields in the hint.
Returns:
translatable_contents_collection: TranslatableContentsCollection.
An instance of TranslatableContentsCollection class.
"""
translatable_contents_collection = (
translation_domain.TranslatableContentsCollection())
translatable_contents_collection.add_translatable_field(
self.hint_content.content_id,
translation_domain.ContentType.HINT,
translation_domain.TranslatableContentFormat.HTML,
self.hint_content.html)
return translatable_contents_collection
def to_dict(self) -> HintDict:
"""Returns a dict representing this Hint domain object.
Returns:
dict. A dict mapping the field of Hint instance.
"""
return {
'hint_content': self.hint_content.to_dict(),
}
# TODO(#16467): Remove `validate` argument after validating all Question
# states by writing a migration and audit job. As the validation for
# hint is common between Exploration and Question and the Question
# data is not yet migrated, we do not want to call the validations
# while we load the Question.
@classmethod
def from_dict(cls, hint_dict: HintDict, validate: bool = True) -> Hint:
"""Return a Hint domain object from a dict.
Args:
hint_dict: dict. The dict representation of Hint object.
validate: bool. False, when the validations should not be called.
Returns:
Hint. The corresponding Hint domain object.
"""
hint_content = SubtitledHtml.from_dict(hint_dict['hint_content'])
if validate:
hint_content.validate()
return cls(hint_content)
def validate(self) -> None:
"""Validates all properties of Hint."""
self.hint_content.validate()
@staticmethod
def convert_html_in_hint(
hint_dict: HintDict, conversion_fn: Callable[[str], str]
) -> HintDict:
"""Checks for HTML fields in the hints and converts it
according to the conversion function.
Args:
hint_dict: dict. The hints dict.
conversion_fn: function. The function to be used for converting the
HTML.
Returns:
dict. The converted hints dict.
"""
hint_dict['hint_content']['html'] = (
conversion_fn(hint_dict['hint_content']['html']))
return hint_dict
class SolutionDict(TypedDict):
"""Dictionary representing the Solution object."""
answer_is_exclusive: bool
correct_answer: AcceptableCorrectAnswerTypes
explanation: SubtitledHtmlDict
class Solution(translation_domain.BaseTranslatableObject):
"""Value object representing a solution.
A solution consists of answer_is_exclusive, correct_answer and an
explanation.When answer_is_exclusive is True, this indicates that it is
the only correct answer; when it is False, this indicates that it is one
possible answer. correct_answer records an answer that enables the learner
to progress to the next card and explanation is an HTML string containing
an explanation for the solution.
"""
def __init__(
self,
interaction_id: str,
answer_is_exclusive: bool,
correct_answer: AcceptableCorrectAnswerTypes,
explanation: SubtitledHtml
) -> None:
"""Constructs a Solution domain object.
Args:
interaction_id: str. The interaction id.
answer_is_exclusive: bool. True if is the only correct answer;
False if is one of possible answer.
correct_answer: *. The correct answer; this answer
enables the learner to progress to the next card. The type of
correct_answer is determined by the value of
BaseInteraction.answer_type. Some examples for the types are
list(set(str)), list(str), str, dict(str, str), etc.
explanation: SubtitledHtml. Contains text and text id to link audio
translations for the solution's explanation.
"""
self.answer_is_exclusive = answer_is_exclusive
self.correct_answer = (
interaction_registry.Registry.get_interaction_by_id(
interaction_id).normalize_answer(correct_answer))
self.explanation = explanation
def get_translatable_contents_collection(
self,
**kwargs: Optional[str]
) -> translation_domain.TranslatableContentsCollection:
"""Get all translatable fields in the solution.
Returns:
translatable_contents_collection: TranslatableContentsCollection.
An instance of TranslatableContentsCollection class.
"""
translatable_contents_collection = (
translation_domain.TranslatableContentsCollection())
translatable_contents_collection.add_translatable_field(
self.explanation.content_id,
translation_domain.ContentType.SOLUTION,
translation_domain.TranslatableContentFormat.HTML,
self.explanation.html)
return translatable_contents_collection
def to_dict(self) -> SolutionDict:
"""Returns a dict representing this Solution domain object.
Returns:
dict. A dict mapping all fields of Solution instance.
"""
return {
'answer_is_exclusive': self.answer_is_exclusive,
'correct_answer': self.correct_answer,
'explanation': self.explanation.to_dict(),
}
# TODO(#16467): Remove `validate` argument after validating all Question
# states by writing a migration and audit job. As the validation for
# solution is common between Exploration and Question and the Question
# data is not yet migrated, we do not want to call the validations
# while we load the Question.
@classmethod
def from_dict(
cls,
interaction_id: str,
solution_dict: SolutionDict,
validate: bool = True
) -> Solution:
"""Return a Solution domain object from a dict.
Args:
interaction_id: str. The interaction id.
solution_dict: dict. The dict representation of Solution object.
validate: bool. False, when the validations should not be called.
Returns:
Solution. The corresponding Solution domain object.
"""
explanation = SubtitledHtml.from_dict(solution_dict['explanation'])
if validate:
explanation.validate()
return cls(
interaction_id,
solution_dict['answer_is_exclusive'],
interaction_registry.Registry.get_interaction_by_id(
interaction_id).normalize_answer(
solution_dict['correct_answer']),
explanation)
def validate(self, interaction_id: str) -> None:
"""Validates all properties of Solution.
Args:
interaction_id: str. The interaction id.
Raises:
ValidationError. One or more attributes of the Solution are not
valid.
"""
if not isinstance(self.answer_is_exclusive, bool):
raise utils.ValidationError(
'Expected answer_is_exclusive to be bool, received %s' %
self.answer_is_exclusive)
interaction_registry.Registry.get_interaction_by_id(
interaction_id).normalize_answer(self.correct_answer)
self.explanation.validate()
@staticmethod
def convert_html_in_solution(
interaction_id: Optional[str],
solution_dict: SolutionDict,
conversion_fn: Callable[[str], str],
html_field_types_to_rule_specs: Dict[
str, rules_registry.RuleSpecsExtensionDict
],
interaction_spec: base.BaseInteractionDict
) -> SolutionDict:
"""Checks for HTML fields in a solution and convert it according
to the conversion function.
Args:
interaction_id: Optional[str]. The interaction id.
solution_dict: dict. The Solution dict.
conversion_fn: function. The function to be used for converting the
HTML.
html_field_types_to_rule_specs: dict. A dictionary that specifies
the locations of html fields in rule specs. It is defined as a
mapping of rule input types to a dictionary containing
interaction id, format, and rule types. See
html_field_types_to_rule_specs_state_v41.json for an example.
interaction_spec: dict. The specification for the interaction.
Returns:
dict. The converted Solution dict.
Raises:
Exception. The Solution dict has an invalid answer type.
"""
if interaction_id is None:
return solution_dict
solution_dict['explanation']['html'] = (
conversion_fn(solution_dict['explanation']['html']))
if interaction_spec['can_have_solution']:
if solution_dict['correct_answer']:
for html_type in html_field_types_to_rule_specs.keys():
if html_type == interaction_spec['answer_type']:
if (
html_type ==
feconf.ANSWER_TYPE_LIST_OF_SETS_OF_HTML):
# Here correct_answer can only be of type
# List[List[str]] because here html_type is
# 'ListOfSetsOfHtmlStrings'.
assert isinstance(
solution_dict['correct_answer'], list
)
for list_index, html_list in enumerate(
solution_dict['correct_answer']):
assert isinstance(html_list, list)
for answer_html_index, answer_html in enumerate(
html_list):
# Here we use cast because above assert
# conditions forces correct_answer to be of
# type List[List[str]].
correct_answer = cast(
List[List[str]],
solution_dict['correct_answer']
)
correct_answer[list_index][
answer_html_index] = (
conversion_fn(answer_html))
elif html_type == feconf.ANSWER_TYPE_SET_OF_HTML:
# Here correct_answer can only be of type
# List[str] because here html_type is
# 'SetOfHtmlString'.
assert isinstance(
solution_dict['correct_answer'], list
)
for answer_html_index, answer_html in enumerate(
solution_dict['correct_answer']):
assert isinstance(answer_html, str)
# Here we use cast because above assert
# conditions forces correct_answer to be of
# type List[str].
set_of_html_correct_answer = cast(
List[str],
solution_dict['correct_answer']
)
set_of_html_correct_answer[
answer_html_index] = (
conversion_fn(answer_html))
else:
raise Exception(
'The solution does not have a valid '
'correct_answer type.')
return solution_dict
class InteractionInstanceDict(TypedDict):
"""Dictionary representing the InteractionInstance object."""
id: Optional[str]
customization_args: CustomizationArgsDictType
answer_groups: List[AnswerGroupDict]
default_outcome: Optional[OutcomeDict]
confirmed_unclassified_answers: List[AnswerGroup]
hints: List[HintDict]
solution: Optional[SolutionDict]
class InteractionInstance(translation_domain.BaseTranslatableObject):
"""Value object for an instance of an interaction."""
class RangeVariableDict(TypedDict):
"""Dictionary representing the range variable for the NumericInput
interaction.
"""
ans_group_index: int
rule_spec_index: int
lower_bound: Optional[float]
upper_bound: Optional[float]
lb_inclusive: bool
ub_inclusive: bool
class MatchedDenominatorDict(TypedDict):
"""Dictionary representing the matched denominator variable for the
FractionInput interaction.
"""
ans_group_index: int
rule_spec_index: int
denominator: int
# The default interaction used for a new state.
_DEFAULT_INTERACTION_ID = None
def __init__(
self,
interaction_id: Optional[str],
customization_args: Dict[str, InteractionCustomizationArg],
answer_groups: List[AnswerGroup],
default_outcome: Optional[Outcome],
confirmed_unclassified_answers: List[AnswerGroup],
hints: List[Hint],
solution: Optional[Solution]
) -> None:
"""Initializes a InteractionInstance domain object.
Args:
interaction_id: Optional[str]. The interaction id.
customization_args: dict. The customization dict. The keys are
names of customization_args and the values are dicts with a
single key, 'value', whose corresponding value is the value of
the customization arg.
answer_groups: list(AnswerGroup). List of answer groups of the
interaction instance.
default_outcome: Optional[Outcome]. The default outcome of the
interaction instance, or None if no default outcome exists
for the interaction.
confirmed_unclassified_answers: list(*). List of answers which have
been confirmed to be associated with the default outcome.
hints: list(Hint). List of hints for this interaction.
solution: Solution|None. A possible solution for the question asked
in this interaction, or None if no solution exists for the
interaction.
"""
self.id = interaction_id
# Customization args for the interaction's view. Parts of these
# args may be Jinja templates that refer to state parameters.
# This is a dict: the keys are names of customization_args and the
# values are dicts with a single key, 'value', whose corresponding
# value is the value of the customization arg.
self.customization_args = customization_args
self.answer_groups = answer_groups
self.default_outcome = default_outcome
self.confirmed_unclassified_answers = confirmed_unclassified_answers
self.hints = hints
self.solution = solution
def get_translatable_contents_collection(
self,
**kwargs: Optional[str]
) -> translation_domain.TranslatableContentsCollection:
"""Get all translatable fields in the interaction instance.
Returns:
translatable_contents_collection: TranslatableContentsCollection.
An instance of TranslatableContentsCollection class.
"""
translatable_contents_collection = (
translation_domain.TranslatableContentsCollection())
if self.default_outcome is not None:
(
translatable_contents_collection
.add_fields_from_translatable_object(self.default_outcome)
)
for answer_group in self.answer_groups:
(
translatable_contents_collection
.add_fields_from_translatable_object(
answer_group,
interaction_id=self.id
)
)
for customization_arg in self.customization_args.values():
(
translatable_contents_collection
.add_fields_from_translatable_object(
customization_arg,
interaction_id=self.id)
)
for hint in self.hints:
(
translatable_contents_collection
.add_fields_from_translatable_object(hint)
)
if self.solution is not None:
(
translatable_contents_collection
.add_fields_from_translatable_object(self.solution)
)
return translatable_contents_collection
def to_dict(self) -> InteractionInstanceDict:
"""Returns a dict representing this InteractionInstance domain object.
Returns:
dict. A dict mapping all fields of InteractionInstance instance.
"""
# customization_args_dict here indicates a dict that maps customization
# argument names to a customization argument dict, the dict
# representation of InteractionCustomizationArg.
customization_args_dict = {}
if self.id:
for ca_name in self.customization_args:
customization_args_dict[ca_name] = (
self.customization_args[
ca_name].to_customization_arg_dict()
)
# Consistent with other usages of to_dict() across the codebase, all
# values below are plain Python data structures and not domain objects,
# despite the names of the keys. This applies to customization_args_dict
# below.
return {
'id': self.id,
'customization_args': customization_args_dict,
'answer_groups': [group.to_dict() for group in self.answer_groups],
'default_outcome': (
self.default_outcome.to_dict()
if self.default_outcome is not None
else None),
'confirmed_unclassified_answers': (
self.confirmed_unclassified_answers),
'hints': [hint.to_dict() for hint in self.hints],
'solution': self.solution.to_dict() if self.solution else None,
}
# TODO(#16467): Remove `validate` argument after validating all Question
# states by writing a migration and audit job. As the validation for
# interaction is common between Exploration and Question and the Question
# data is not yet migrated, we do not want to call the validations
# while we load the Question.
@classmethod
def from_dict(
cls, interaction_dict: InteractionInstanceDict, validate: bool = True
) -> InteractionInstance:
"""Return a InteractionInstance domain object from a dict.
Args:
interaction_dict: dict. The dict representation of
InteractionInstance object.
validate: bool. False, when the validations should not be called.
Returns:
InteractionInstance. The corresponding InteractionInstance domain
object.
"""
default_outcome_dict = (
Outcome.from_dict(
interaction_dict['default_outcome'], validate=validate)
if interaction_dict['default_outcome'] is not None else None)
solution_dict = (
Solution.from_dict(
interaction_dict['id'], interaction_dict['solution'],
validate=validate)
if (
interaction_dict['solution'] is not None and
interaction_dict['id'] is not None
)
else None)
customization_args = (
InteractionInstance
.convert_customization_args_dict_to_customization_args(
interaction_dict['id'],
interaction_dict['customization_args']
)
)
return cls(
interaction_dict['id'],
customization_args,
(
[AnswerGroup.from_dict(h, validate=validate)
for h in interaction_dict['answer_groups']]
),
default_outcome_dict,
interaction_dict['confirmed_unclassified_answers'],
(
[Hint.from_dict(h, validate=validate)
for h in interaction_dict['hints']]
),
solution_dict)
@property
def is_terminal(self) -> bool:
"""Determines if this interaction type is terminal. If no ID is set for
this interaction, it is assumed to not be terminal.
Returns:
bool. Whether the interaction is terminal.
"""
return bool(
self.id and interaction_registry.Registry.get_interaction_by_id(
self.id
).is_terminal
)
@property
def is_linear(self) -> bool:
"""Determines if this interaction type is linear.
Returns:
bool. Whether the interaction is linear.
"""
return interaction_registry.Registry.get_interaction_by_id(
self.id).is_linear
def is_supported_on_android_app(self) -> bool:
"""Determines whether the interaction is a valid interaction that is
supported by the Android app.
Returns:
bool. Whether the interaction is supported by the Android app.
"""
return (
self.id is None or
self.id in android_validation_constants.VALID_INTERACTION_IDS
)
def is_rte_content_supported_on_android(
self, require_valid_component_names: Callable[[str], bool]
) -> bool:
"""Determines whether the RTE content in interaction answer groups,
hints and solution is supported by Android app.
Args:
require_valid_component_names: function. Function to check
whether the RTE tags in the html string are allowed.
Returns:
bool. Whether the RTE content is valid.
"""
for answer_group in self.answer_groups:
if require_valid_component_names(
answer_group.outcome.feedback.html):
return False
if (
self.default_outcome and self.default_outcome.feedback and
require_valid_component_names(
self.default_outcome.feedback.html)):
return False
for hint in self.hints:
if require_valid_component_names(hint.hint_content.html):
return False
if (
self.solution and self.solution.explanation and
require_valid_component_names(
self.solution.explanation.html)):
return False
return True
def get_all_outcomes(self) -> List[Outcome]:
"""Returns a list of all outcomes of this interaction, taking into
consideration every answer group and the default outcome.
Returns:
list(Outcome). List of all outcomes of this interaction.
"""
outcomes = []
for answer_group in self.answer_groups:
outcomes.append(answer_group.outcome)
if self.default_outcome is not None:
outcomes.append(self.default_outcome)
return outcomes
def _validate_continue_interaction(self) -> None:
"""Validates Continue interaction."""
# Here we use cast because we are narrowing down the type from various
# customization args value types to 'SubtitledUnicode' type, and this
# is done because here we are accessing 'buttontext' key from continue
# customization arg whose value is always of SubtitledUnicode type.
button_text_subtitled_unicode = cast(
SubtitledUnicode,
self.customization_args['buttonText'].value
)
text_value = button_text_subtitled_unicode.unicode_str
if len(text_value) > 20:
raise utils.ValidationError(
'The `continue` interaction text length should be atmost '
'20 characters.'
)
def _validate_end_interaction(self) -> None:
"""Validates End interaction."""
# Here we use cast because we are narrowing down the type
# from various customization args value types to List[str]
# type, and this is done because here we are accessing
# 'recommendedExplorationIds' key from EndExploration
# customization arg whose value is always of List[str] type.
recc_exp_ids = cast(
List[str],
self.customization_args['recommendedExplorationIds'].value
)
if len(recc_exp_ids) > 3:
raise utils.ValidationError(
'The total number of recommended explorations inside End '
'interaction should be atmost 3.'
)
def _validates_choices_should_be_unique_and_nonempty(
self, choices: List[SubtitledHtml]
) -> None:
"""Validates that the choices should be unique and non empty.
Args:
choices: List[state_domain.SubtitledHtml]. Choices that needs to
be validated.
Raises:
utils.ValidationError. Choice is empty.
utils.ValidationError. Choice is duplicate.
"""
seen_choices = []
for choice in choices:
if html_cleaner.is_html_empty(choice.html):
raise utils.ValidationError(
'Choices should be non empty.'
)
if choice.html not in seen_choices:
seen_choices.append(choice.html)
else:
raise utils.ValidationError(
'Choices should be unique.'
)