ripple-down-rules 0.6.28__py3-none-any.whl → 0.6.30__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- __version__ = "0.6.28"
1
+ __version__ = "0.6.30"
2
2
 
3
3
  import logging
4
4
  logger = logging.Logger("rdr")
@@ -120,7 +120,9 @@ class CallableExpression(SubclassJSONSerializer):
120
120
  self.user_defined_name = user_input.split('(')[0].replace('def ', '')
121
121
  else:
122
122
  self.user_defined_name = user_input
123
- self._user_input: str = encapsulate_user_input(user_input, self.get_encapsulating_function())
123
+ if f"def {self.encapsulating_function_name}" not in user_input:
124
+ user_input = encapsulate_user_input(user_input, self.get_encapsulating_function())
125
+ self._user_input: str = user_input
124
126
  if conclusion_type is not None:
125
127
  if is_iterable(conclusion_type):
126
128
  conclusion_type = tuple(conclusion_type)
@@ -308,7 +310,7 @@ def parse_string_to_expression(expression_str: str) -> AST:
308
310
  :param expression_str: The string which will be parsed.
309
311
  :return: The parsed expression.
310
312
  """
311
- if not expression_str.startswith(CallableExpression.get_encapsulating_function()):
313
+ if not expression_str.startswith(f"def {CallableExpression.encapsulating_function_name}"):
312
314
  expression_str = encapsulate_user_input(expression_str, CallableExpression.get_encapsulating_function())
313
315
  mode = 'exec' if expression_str.startswith('def') else 'eval'
314
316
  tree = ast.parse(expression_str, mode=mode)
@@ -20,19 +20,20 @@ if TYPE_CHECKING:
20
20
 
21
21
  class Case(UserDict, SubclassJSONSerializer):
22
22
  """
23
- A collection of attributes that represents a set of constraints on a case. This is a dictionary where the keys are
24
- the names of the attributes and the values are the attributes. All are stored in lower case.
23
+ A collection of attributes that represents a set of attributes of a case. This is a dictionary where the keys are
24
+ the names of the attributes and the values are the attributes. All are stored in lower case, and can be accessed
25
+ using the dot notation as well as the dictionary access notation.
25
26
  """
26
27
 
27
28
  def __init__(self, _obj_type: Type, _id: Optional[Hashable] = None,
28
29
  _name: Optional[str] = None, original_object: Optional[Any] = None, **kwargs):
29
30
  """
30
- Create a new row.
31
+ Create a new case.
31
32
 
32
- :param _obj_type: The type of the object that the row represents.
33
- :param _id: The id of the row.
34
- :param _name: The semantic name that describes the row.
35
- :param kwargs: The attributes of the row.
33
+ :param _obj_type: The original type of the object that the case represents.
34
+ :param _id: The id of the case.
35
+ :param _name: The semantic name that describes the case.
36
+ :param kwargs: The attributes of the case.
36
37
  """
37
38
  super().__init__(kwargs)
38
39
  self._original_object = original_object
@@ -43,12 +44,12 @@ class Case(UserDict, SubclassJSONSerializer):
43
44
  @classmethod
44
45
  def from_obj(cls, obj: Any, obj_name: Optional[str] = None, max_recursion_idx: int = 3) -> Case:
45
46
  """
46
- Create a row from an object.
47
+ Create a case from an object.
47
48
 
48
- :param obj: The object to create a row from.
49
+ :param obj: The object to create a case from.
49
50
  :param max_recursion_idx: The maximum recursion index to prevent infinite recursion.
50
51
  :param obj_name: The name of the object.
51
- :return: The row of the object.
52
+ :return: The case that represents the object.
52
53
  """
53
54
  return create_case(obj, max_recursion_idx=max_recursion_idx, obj_name=obj_name)
54
55
 
@@ -129,7 +130,7 @@ class Case(UserDict, SubclassJSONSerializer):
129
130
  @dataclass
130
131
  class CaseAttributeValue(SubclassJSONSerializer):
131
132
  """
132
- A column value is a value in a column.
133
+ Encapsulates a single value of a case attribute, it adds an id to the value.
133
134
  """
134
135
  id: Hashable
135
136
  """
@@ -1,19 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import inspect
4
- import typing
4
+ import uuid
5
5
  from dataclasses import dataclass, field
6
6
 
7
- import typing_extensions
7
+ from colorama import Fore, Style
8
8
  from omegaconf import MISSING
9
9
  from sqlalchemy.orm import DeclarativeBase as SQLTable
10
- from typing_extensions import Any, Optional, Dict, Type, Tuple, Union, List, get_origin, Set, Callable
10
+ from typing_extensions import Any, Optional, Dict, Type, Tuple, Union, List, Set, Callable, TYPE_CHECKING
11
11
 
12
- from ..utils import get_method_name, get_function_import_data, get_function_representation
13
12
  from .callable_expression import CallableExpression
14
13
  from .case import create_case, Case
15
- from ..utils import copy_case, make_list, make_set, get_origin_and_args_from_type_hint, get_value_type_from_type_hint, \
16
- typing_to_python_type
14
+ from ..utils import copy_case, make_list, make_set, get_origin_and_args_from_type_hint, render_tree, \
15
+ get_function_representation
16
+
17
+ if TYPE_CHECKING:
18
+ from ..rdr import RippleDownRules
19
+ from ..rules import Rule
17
20
 
18
21
 
19
22
  @dataclass
@@ -92,6 +95,38 @@ class CaseQuery:
92
95
  """
93
96
  The type hints of the function arguments. This is used to recreate the function signature.
94
97
  """
98
+ rdr: Optional[RippleDownRules] = None
99
+ """
100
+ The Ripple Down Rules that was used to answer the case query.
101
+ """
102
+
103
+ def render_rule_tree(self, filepath: Optional[str] = None, view: bool = False):
104
+ if self.rdr is None:
105
+ return
106
+ render_tree(self.rdr.start_rule, use_dot_exporter=True, filename=filepath, view=view)
107
+
108
+ @property
109
+ def current_value_str(self):
110
+ return (f"{Fore.MAGENTA}Current value of {Fore.CYAN}{self.name}{Fore.MAGENTA} of type(s) "
111
+ f"{Fore.CYAN}({self.core_attribute_type_str}){Fore.MAGENTA}: "
112
+ f"{Fore.WHITE}{self.current_value}{Style.RESET_ALL}")
113
+
114
+ @property
115
+ def current_value(self) -> Any:
116
+ """
117
+ :return: The current value of the attribute.
118
+ """
119
+ if not hasattr(self.case, self.attribute_name):
120
+ return None
121
+
122
+ attr_value = getattr(self.case, self.attribute_name)
123
+
124
+ if attr_value is None:
125
+ return attr_value
126
+ elif self.mutually_exclusive:
127
+ return attr_value
128
+ else:
129
+ return list({v for v in make_list(attr_value) if isinstance(v, self.core_attribute_type)})
95
130
 
96
131
  @property
97
132
  def case_type(self) -> Type:
@@ -145,7 +180,14 @@ class CaseQuery:
145
180
  return attribute_types_str
146
181
 
147
182
  @property
148
- def core_attribute_type(self) -> Tuple[Type]:
183
+ def core_attribute_type_str(self) -> str:
184
+ """
185
+ :return: The names of the core types of the attribute.
186
+ """
187
+ return ','.join([t.__name__ for t in self.core_attribute_type])
188
+
189
+ @property
190
+ def core_attribute_type(self) -> Tuple[Type, ...]:
149
191
  """
150
192
  :return: The core type of the attribute.
151
193
  """
@@ -247,7 +289,7 @@ class CaseQuery:
247
289
  conditions=self.conditions, is_function=self.is_function,
248
290
  function_args_type_hints=self.function_args_type_hints,
249
291
  case_factory=self.case_factory, case_factory_idx=self.case_factory_idx,
250
- case_conf=self.case_conf, scenario=self.scenario)
292
+ case_conf=self.case_conf, scenario=self.scenario, rdr=self.rdr)
251
293
 
252
294
 
253
295
  @dataclass
@@ -284,4 +326,40 @@ class CaseFactoryMetaData:
284
326
  f" scenario={scenario_repr})")
285
327
 
286
328
  def __str__(self):
287
- return self.__repr__()
329
+ return self.__repr__()
330
+
331
+
332
+ @dataclass
333
+ class RDRConclusion:
334
+ """
335
+ This dataclass represents a conclusion of a Ripple Down Rule.
336
+ It contains the conclusion expression, the type of the conclusion, and the scope in which it is evaluated.
337
+ """
338
+ value: Any
339
+ """
340
+ The conclusion value.
341
+ """
342
+ frozen_case: Any
343
+ """
344
+ The frozen case that the conclusion was made for.
345
+ """
346
+ rule: Rule
347
+ """
348
+ The rule that gave this conclusion.
349
+ """
350
+ rdr: RippleDownRules
351
+ """
352
+ The Ripple Down Rules that classified the case and produced this conclusion.
353
+ """
354
+ id: int = field(default_factory=lambda: uuid.uuid4().int)
355
+ """
356
+ The unique identifier of the conclusion.
357
+ """
358
+
359
+ def __hash__(self):
360
+ return hash(self.id)
361
+
362
+ def __eq__(self, other):
363
+ if not isinstance(other, RDRConclusion):
364
+ return False
365
+ return self.id == other.id
@@ -1,19 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ import importlib
4
5
  import json
5
- import logging
6
6
  import os
7
- import uuid
7
+ import sys
8
8
  from abc import ABC, abstractmethod
9
+ from dataclasses import is_dataclass
10
+ from textwrap import dedent, indent
11
+ from typing import Tuple, Dict
9
12
 
10
13
  from typing_extensions import Optional, TYPE_CHECKING, List
11
14
 
12
15
  from .datastructures.callable_expression import CallableExpression
13
- from .datastructures.enums import PromptFor
14
- from .datastructures.dataclasses import CaseQuery
15
16
  from .datastructures.case import show_current_and_corner_cases
16
- from .utils import extract_imports, extract_function_source, get_imports_from_scope, encapsulate_user_input
17
+ from .datastructures.dataclasses import CaseQuery
18
+ from .datastructures.enums import PromptFor
19
+ from .user_interface.template_file_creator import TemplateFileCreator
20
+ from .utils import extract_imports, extract_function_source, get_imports_from_scope, get_class_file_path
17
21
 
18
22
  try:
19
23
  from .user_interface.gui import RDRCaseViewer
@@ -46,14 +50,13 @@ class Expert(ABC):
46
50
  answers_save_path: Optional[str] = None):
47
51
  self.all_expert_answers = []
48
52
  self.use_loaded_answers = use_loaded_answers
49
- self.append = append
50
53
  self.answers_save_path = answers_save_path
51
54
  if answers_save_path is not None and os.path.exists(answers_save_path + '.py'):
52
55
  if use_loaded_answers:
53
56
  self.load_answers(answers_save_path)
54
- else:
57
+ if not append:
55
58
  os.remove(answers_save_path + '.py')
56
- self.append = True
59
+ self.append = True
57
60
 
58
61
  @abstractmethod
59
62
  def ask_for_conditions(self, case_query: CaseQuery, last_evaluated_rule: Optional[Rule] = None) \
@@ -86,31 +89,29 @@ class Expert(ABC):
86
89
  """
87
90
  if path is None and self.answers_save_path is None:
88
91
  raise ValueError("No path provided to clear expert answers, either provide a path or set the "
89
- "answers_save_path attribute.")
92
+ "answers_save_path attribute.")
90
93
  if path is None:
91
94
  path = self.answers_save_path
92
- if os.path.exists(path + '.json'):
93
- os.remove(path + '.json')
94
95
  if os.path.exists(path + '.py'):
95
96
  os.remove(path + '.py')
96
97
  self.all_expert_answers = []
97
98
 
98
- def save_answers(self, path: Optional[str] = None):
99
+ def save_answers(self, path: Optional[str] = None, expert_answers: Optional[List[Tuple[Dict, str]]] = None):
99
100
  """
100
101
  Save the expert answers to a file.
101
102
 
102
103
  :param path: The path to save the answers to.
104
+ :param expert_answers: The expert answers to save.
103
105
  """
106
+ expert_answers = expert_answers if expert_answers else self.all_expert_answers
107
+ if not any(expert_answers):
108
+ return
104
109
  if path is None and self.answers_save_path is None:
105
110
  raise ValueError("No path provided to save expert answers, either provide a path or set the "
106
- "answers_save_path attribute.")
111
+ "answers_save_path attribute.")
107
112
  if path is None:
108
113
  path = self.answers_save_path
109
- is_json = os.path.exists(path + '.json')
110
- if is_json:
111
- self._save_to_json(path)
112
- else:
113
- self._save_to_python(path)
114
+ self._save_to_python(path, expert_answers=expert_answers)
114
115
 
115
116
  def _save_to_json(self, path: str):
116
117
  """
@@ -127,12 +128,14 @@ class Expert(ABC):
127
128
  with open(path + '.json', "w") as f:
128
129
  json.dump(all_answers, f)
129
130
 
130
- def _save_to_python(self, path: str):
131
+ def _save_to_python(self, path: str, expert_answers: Optional[List[Tuple[Dict, str]]] = None):
131
132
  """
132
133
  Save the expert answers to a Python file.
133
134
 
134
135
  :param path: The path to save the answers to.
136
+ :param expert_answers: The expert answers to save.
135
137
  """
138
+ expert_answers = expert_answers if expert_answers else self.all_expert_answers
136
139
  dir_name = os.path.dirname(path)
137
140
  if not os.path.exists(dir_name + '/__init__.py'):
138
141
  os.makedirs(dir_name, exist_ok=True)
@@ -145,18 +148,13 @@ class Expert(ABC):
145
148
  current_file_data = f.read()
146
149
  action = 'a' if self.append and current_file_data is not None else 'w'
147
150
  with open(path + '.py', action) as f:
148
- for scope, func_source in self.all_expert_answers:
151
+ for scope, func_source in expert_answers:
149
152
  if len(scope) > 0:
150
153
  imports = '\n'.join(get_imports_from_scope(scope)) + '\n\n\n'
151
154
  else:
152
155
  imports = ''
153
- if func_source is not None:
154
- uid = uuid.uuid4().hex
155
- func_source = encapsulate_user_input(func_source, CallableExpression.get_encapsulating_function(f'_{uid}'))
156
- else:
156
+ if func_source is None:
157
157
  func_source = 'pass # No user input provided for this case.\n'
158
- if current_file_data is not None and func_source[1:] in current_file_data:
159
- continue
160
158
  f.write(imports + func_source + '\n' + '\n\n\n\'===New Answer===\'\n\n\n')
161
159
 
162
160
  def load_answers(self, path: Optional[str] = None):
@@ -167,14 +165,13 @@ class Expert(ABC):
167
165
  """
168
166
  if path is None and self.answers_save_path is None:
169
167
  raise ValueError("No path provided to load expert answers from, either provide a path or set the "
170
- "answers_save_path attribute.")
168
+ "answers_save_path attribute.")
171
169
  if path is None:
172
170
  path = self.answers_save_path
173
- is_json = os.path.exists(path + '.json')
174
- if is_json:
175
- self._load_answers_from_json(path)
176
- elif os.path.exists(path + '.py'):
171
+ if os.path.exists(path + '.py'):
177
172
  self._load_answers_from_python(path)
173
+ elif os.path.exists(path + '.json'):
174
+ self._load_answers_from_json(path)
178
175
 
179
176
  def _load_answers_from_json(self, path: str):
180
177
  """
@@ -195,19 +192,94 @@ class Expert(ABC):
195
192
  file_path = path + '.py'
196
193
  with open(file_path, "r") as f:
197
194
  all_answers = f.read().split('\n\n\n\'===New Answer===\'\n\n\n')[:-1]
198
- all_function_sources = list(extract_function_source(file_path, []).values())
199
- all_function_sources_names = list(extract_function_source(file_path, []).keys())
195
+ all_function_sources = extract_function_source(file_path, [], as_list=True)
200
196
  for i, answer in enumerate(all_answers):
201
197
  answer = answer.strip('\n').strip()
202
198
  if 'def ' not in answer and 'pass' in answer:
203
199
  self.all_expert_answers.append(({}, None))
204
200
  continue
205
201
  scope = extract_imports(tree=ast.parse(answer))
206
- function_source = all_function_sources[i].replace(all_function_sources_names[i],
202
+ func_name = all_function_sources[i].split('def ')[1].split('(')[0]
203
+ function_source = all_function_sources[i].replace(func_name,
207
204
  CallableExpression.encapsulating_function_name)
208
205
  self.all_expert_answers.append((scope, function_source))
209
206
 
210
207
 
208
+ class AI(Expert):
209
+ """
210
+ The AI Expert class, an expert that uses AI to provide differentiating features and conclusions.
211
+ """
212
+
213
+ def __init__(self, **kwargs):
214
+ """
215
+ Initialize the AI expert.
216
+ """
217
+ super().__init__(**kwargs)
218
+ self.user_prompt = UserPrompt()
219
+
220
+ def ask_for_conditions(self, case_query: CaseQuery,
221
+ last_evaluated_rule: Optional[Rule] = None) \
222
+ -> CallableExpression:
223
+ prompt_str = self.get_prompt_for_ai(case_query, PromptFor.Conditions)
224
+ print(prompt_str)
225
+ sys.exit()
226
+
227
+ def ask_for_conclusion(self, case_query: CaseQuery) -> Optional[CallableExpression]:
228
+ prompt_str = self.get_prompt_for_ai(case_query, PromptFor.Conclusion)
229
+ output_type_source = self.get_output_type_class_source(case_query)
230
+ prompt_str = f"\n\n\nOutput type(s) class source:\n{output_type_source}\n\n" + prompt_str
231
+ print(prompt_str)
232
+ sys.exit()
233
+
234
+ def get_output_type_class_source(self, case_query: CaseQuery) -> str:
235
+ """
236
+ Get the output type class source for the AI expert.
237
+
238
+ :param case_query: The case query containing the case to classify.
239
+ :return: The output type class source.
240
+ """
241
+ output_types = case_query.core_attribute_type
242
+
243
+ def get_class_source(cls):
244
+ cls_source_file = get_class_file_path(cls)
245
+ found_class_source = extract_function_source(cls_source_file, function_names=[cls.__name__],
246
+ is_class=True,
247
+ as_list=True)[0]
248
+ class_signature = found_class_source.split('\n')[0]
249
+ if '(' in class_signature:
250
+ parent_class_names = list(map(lambda x: x.strip(),
251
+ class_signature.split('(')[1].split(')')[0].split(',')))
252
+ parent_classes = [importlib.import_module(cls.__module__).__dict__.get(cls_name.strip())
253
+ for cls_name in parent_class_names]
254
+ else:
255
+ parent_classes = []
256
+ if is_dataclass(cls):
257
+ found_class_source = f"@dataclass\n{found_class_source}"
258
+ return '\n'.join([get_class_source(pcls) for pcls in parent_classes] + [found_class_source])
259
+
260
+ found_class_sources = []
261
+ for output_type in output_types:
262
+ found_class_sources.append(get_class_source(output_type))
263
+ found_class_sources = '\n\n\n'.join(found_class_sources)
264
+ return found_class_sources
265
+
266
+ def get_prompt_for_ai(self, case_query: CaseQuery, prompt_for: PromptFor) -> str:
267
+ """
268
+ Get the prompt for the AI expert.
269
+
270
+ :param case_query: The case query containing the case to classify.
271
+ :param prompt_for: The type of prompt to get.
272
+ :return: The prompt for the AI expert.
273
+ """
274
+ # data_to_show = show_current_and_corner_cases(case_query.case)
275
+ data_to_show = f"\nCase ({case_query.case_name}):\n {case_query.case.__dict__}"
276
+ template_file_creator = TemplateFileCreator(case_query, prompt_for=prompt_for)
277
+ boilerplate_code = template_file_creator.build_boilerplate_code()
278
+ initial_prompt_str = data_to_show + "\n\n" + boilerplate_code + "\n\n"
279
+ return self.user_prompt.build_prompt_str_for_ai(case_query, prompt_for=prompt_for,
280
+ initial_prompt_str=initial_prompt_str)
281
+
282
+
211
283
  class Human(Expert):
212
284
  """
213
285
  The Human Expert class, an expert that asks the human to provide differentiating features and conclusions.
@@ -225,8 +297,9 @@ class Human(Expert):
225
297
  -> CallableExpression:
226
298
  data_to_show = None
227
299
  if (not self.use_loaded_answers or len(self.all_expert_answers) == 0) and self.user_prompt.viewer is None:
228
- data_to_show = show_current_and_corner_cases(case_query.case, {case_query.attribute_name: case_query.target_value},
229
- last_evaluated_rule=last_evaluated_rule)
300
+ data_to_show = show_current_and_corner_cases(case_query.case,
301
+ {case_query.attribute_name: case_query.target_value},
302
+ last_evaluated_rule=last_evaluated_rule)
230
303
  return self._get_conditions(case_query, data_to_show)
231
304
 
232
305
  def _get_conditions(self, case_query: CaseQuery, data_to_show: Optional[str] = None) \
@@ -249,10 +322,13 @@ class Human(Expert):
249
322
  if user_input is not None:
250
323
  case_query.scope.update(loaded_scope)
251
324
  condition = CallableExpression(user_input, bool, scope=case_query.scope)
325
+ if self.answers_save_path is not None and not any(loaded_scope):
326
+ self.convert_json_answer_to_python_answer(case_query, user_input, condition, PromptFor.Conditions)
252
327
  else:
253
- user_input, condition = self.user_prompt.prompt_user_for_expression(case_query, PromptFor.Conditions, prompt_str=data_to_show)
254
- if user_input == 'exit':
255
- exit()
328
+ user_input, condition = self.user_prompt.prompt_user_for_expression(case_query, PromptFor.Conditions,
329
+ prompt_str=data_to_show)
330
+ if user_input in ['exit', 'quit']:
331
+ sys.exit()
256
332
  if not self.use_loaded_answers:
257
333
  self.all_expert_answers.append((condition.scope, user_input))
258
334
  if self.answers_save_path is not None:
@@ -260,6 +336,19 @@ class Human(Expert):
260
336
  case_query.conditions = condition
261
337
  return condition
262
338
 
339
+ def convert_json_answer_to_python_answer(self, case_query: CaseQuery, user_input: str,
340
+ callable_expression: CallableExpression,
341
+ prompt_for: PromptFor):
342
+ tfc = TemplateFileCreator(case_query, prompt_for=prompt_for)
343
+ code = tfc.build_boilerplate_code()
344
+ if user_input.startswith('def'):
345
+ user_input = '\n'.join(user_input.split('\n')[1:])
346
+ user_input = indent(dedent(user_input), " " * 4).strip()
347
+ code = code.replace('pass', user_input)
348
+ else:
349
+ code = code.replace('pass', f"return {user_input}")
350
+ self.save_answers(expert_answers=[({}, code)])
351
+
263
352
  def ask_for_conclusion(self, case_query: CaseQuery) -> Optional[CallableExpression]:
264
353
  """
265
354
  Ask the expert to provide a conclusion for the case.
@@ -279,20 +368,24 @@ class Human(Expert):
279
368
  expression = CallableExpression(expert_input, case_query.attribute_type,
280
369
  scope=case_query.scope,
281
370
  mutually_exclusive=case_query.mutually_exclusive)
371
+ if self.answers_save_path is not None and not any(loaded_scope):
372
+ self.convert_json_answer_to_python_answer(case_query, expert_input, expression,
373
+ PromptFor.Conclusion)
282
374
  except IndexError:
283
375
  self.use_loaded_answers = False
284
376
  if not self.use_loaded_answers:
285
377
  data_to_show = None
286
378
  if self.user_prompt.viewer is None:
287
379
  data_to_show = show_current_and_corner_cases(case_query.case)
288
- expert_input, expression = self.user_prompt.prompt_user_for_expression(case_query, PromptFor.Conclusion, prompt_str=data_to_show)
380
+ expert_input, expression = self.user_prompt.prompt_user_for_expression(case_query, PromptFor.Conclusion,
381
+ prompt_str=data_to_show)
289
382
  if expert_input is None:
290
383
  self.all_expert_answers.append(({}, None))
291
- elif expert_input != 'exit':
384
+ elif expert_input not in ['exit', 'quit']:
292
385
  self.all_expert_answers.append((expression.scope, expert_input))
293
- if self.answers_save_path is not None and expert_input != 'exit':
386
+ if self.answers_save_path is not None and expert_input not in ['exit', 'quit']:
294
387
  self.save_answers()
295
- if expert_input == 'exit':
296
- exit()
388
+ if expert_input in ['exit', 'quit']:
389
+ sys.exit()
297
390
  case_query.target = expression
298
391
  return expression