Repository: RasaHQ/rasa
Pull request: Avoid retraining Core model if only templates section is changed
**Proposed changes**:
- Fix #4216 

**Status (please check what you already did)**:
- [x] made PR ready for code review
- [x] added some tests for the functionality
- [ ] updated the documentation
- [x] updated the changelog
- [x] reformat files using `black` (please check [Readme](https://github.com/RasaHQ/rasa_nlu#code-style) for instructions)


Problem statement:
The whole Core model is retrained even if only the templates section is changed
**Description of Problem**:
Having to retrain the Core ML model every time templates are changed is unnecessary and can be very expensive for a large project with lots of stories.

**Overview of the Solution**:
Create a separate, more granular fingerprint for the templates section in the domain and if only that has changed, simply replace that section in the new model and not retrain Core.

**Examples** (if relevant):
<!-- List examples and/or link to relevant references. -->

**Blockers** (if relevant):
<!-- Is this feature blocked by anything or anyone?
Or might there be any potential blockers on the way? -->

**Definition of Done**:
- [ ] Tests are added
- [ ] Features described in the docs
- [ ] Feature mentioned in the changelog
- [ ] Any updates in Rasa X?
<!-- What needs to be there to consider this feature as done?
- [ ] Tests are added
- [ ] Feature described the docs
- [ ] Feature mentioned in the changlog
- [ ] ... -->

Hints from issue discussion:
This is the default behavior;  the way to avoid retraining with template changes is to use an [External CMS](https://rasa.com/docs/rasa/core/responses/#managing-assistant-utterances-using-an-external-cms).

If there are no changes to the domain or the training data Rasa already prevents redundant training.
> This is the default behavior; the way to avoid retraining with template changes is to use an External CMS.

I don't think the choice of using an external CMS or not should penalize you with unnecessary training time. A change in the text or triggers of the template does not change the outcome of the core model training and is therefore unnecessary, don't you think?
@msamogh this should be `in progress`, shouldn't it?
Yes. Thanks for noticing!
@btotharye additionally to what it says in the issue description here, someone mentioned recently that sometimes NLU retrains if some parts of the domain are changed as well. So we should look into what's going on there
So @btotharye is gonna finish up https://github.com/RasaHQ/rasa/pull/4251 ?
> So @btotharye is gonna finish up #4251 ?

Hrm didn't know that was already out there, should I start with that PR code?
Why isn't @msamogh finishing this up and you @btotharye tackle the NLU part from @akelad 's comment?
I'm good with that if everyone agrees
haha my bad - i assumed this wasn't being tackled since @msamogh had unassigned himself

Commit message: remove leading whitespace

Patch to review:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 039a28f2a612..950e981c3221 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -15,6 +15,7 @@ Added
 
 Changed
 -------
+- Do not retrain the entire Core model if only the ``templates`` section of the domain is changed.
 
 Removed
 -------
@@ -223,7 +224,6 @@ Changed
 -------
 - Pin gast to == 0.2.2
 
-
 [1.3.0] - 2019-09-05
 ^^^^^^^^^^^^^^^^^^^^
 
diff --git a/rasa/constants.py b/rasa/constants.py
index 7538ed3d56fa..a270ab695e18 100644
--- a/rasa/constants.py
+++ b/rasa/constants.py
@@ -9,6 +9,7 @@
 DEFAULT_DATA_PATH = "data"
 DEFAULT_RESULTS_PATH = "results"
 DEFAULT_NLU_RESULTS_PATH = "nlu_comparison_results"
+DEFAULT_CORE_SUBDIRECTORY_NAME = "core"
 DEFAULT_REQUEST_TIMEOUT = 60 * 5  # 5 minutes
 
 TEST_DATA_FILE = "test.md"
diff --git a/rasa/core/agent.py b/rasa/core/agent.py
index 4d42593adef9..49fe4eb39ad7 100644
--- a/rasa/core/agent.py
+++ b/rasa/core/agent.py
@@ -12,7 +12,12 @@
 import rasa
 import rasa.utils.io
 import rasa.core.utils
-from rasa.constants import DEFAULT_DOMAIN_PATH, LEGACY_DOCS_BASE_URL, ENV_SANIC_BACKLOG
+from rasa.constants import (
+    DEFAULT_DOMAIN_PATH,
+    LEGACY_DOCS_BASE_URL,
+    ENV_SANIC_BACKLOG,
+    DEFAULT_CORE_SUBDIRECTORY_NAME,
+)
 from rasa.core import constants, jobs, training
 from rasa.core.channels.channel import InputChannel, OutputChannel, UserMessage
 from rasa.core.constants import DEFAULT_REQUEST_TIMEOUT
@@ -764,8 +769,8 @@ def persist(self, model_path: Text, dump_flattened_stories: bool = False) -> Non
         if not self.is_core_ready():
             raise AgentNotReady("Can't persist without a policy ensemble.")
 
-        if not model_path.endswith("core"):
-            model_path = os.path.join(model_path, "core")
+        if not model_path.endswith(DEFAULT_CORE_SUBDIRECTORY_NAME):
+            model_path = os.path.join(model_path, DEFAULT_CORE_SUBDIRECTORY_NAME)
 
         self._clear_model_directory(model_path)
 
diff --git a/rasa/core/domain.py b/rasa/core/domain.py
index 572ad3d70f29..61b3544aee04 100644
--- a/rasa/core/domain.py
+++ b/rasa/core/domain.py
@@ -3,6 +3,7 @@
 import logging
 import os
 import typing
+from pathlib import Path
 from typing import Any, Dict, List, Optional, Text, Tuple, Union, Set
 
 import rasa.core.constants
@@ -664,7 +665,7 @@ def as_dict(self) -> Dict[Text, Any]:
             "forms": self.form_names,
         }
 
-    def persist(self, filename: Text) -> None:
+    def persist(self, filename: Union[Text, Path]) -> None:
         """Write domain to a file."""
 
         domain_data = self.as_dict()
diff --git a/rasa/core/train.py b/rasa/core/train.py
index 5a8d9e54ed34..3ad453ce3a14 100644
--- a/rasa/core/train.py
+++ b/rasa/core/train.py
@@ -60,7 +60,6 @@ async def train(
             "debug_plots",
         },
     )
-
     training_data = await agent.load_data(
         training_resource, exclusion_percentage=exclusion_percentage, **data_load_args
     )
diff --git a/rasa/model.py b/rasa/model.py
index ac175041dd99..f94d3904896e 100644
--- a/rasa/model.py
+++ b/rasa/model.py
@@ -4,7 +4,9 @@
 import shutil
 import tempfile
 import typing
-from typing import Text, Tuple, Union, Optional, List, Dict
+from collections import namedtuple
+from pathlib import Path
+from typing import Text, Tuple, Union, Optional, List, Dict, Any
 
 import rasa.utils.io
 from rasa.cli.utils import print_success, create_output_path
@@ -13,6 +15,8 @@
     CONFIG_MANDATORY_KEYS_CORE,
     CONFIG_MANDATORY_KEYS_NLU,
     CONFIG_MANDATORY_KEYS,
+    DEFAULT_DOMAIN_PATH,
+    DEFAULT_CORE_SUBDIRECTORY_NAME,
 )
 
 from rasa.core.utils import get_dict_hash
@@ -22,25 +26,94 @@
 if typing.TYPE_CHECKING:
     from rasa.importers.importer import TrainingDataImporter
 
-# Type alias for the fingerprint
-Fingerprint = Dict[Text, Union[Text, List[Text], int, float]]
 
 logger = logging.getLogger(__name__)
 
+
+# Type alias for the fingerprint
+Fingerprint = Dict[Text, Union[Text, List[Text], int, float]]
+
 FINGERPRINT_FILE_PATH = "fingerprint.json"
 
 FINGERPRINT_CONFIG_KEY = "config"
 FINGERPRINT_CONFIG_CORE_KEY = "core-config"
 FINGERPRINT_CONFIG_NLU_KEY = "nlu-config"
-FINGERPRINT_DOMAIN_KEY = "domain"
+FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY = "domain"
+FINGERPRINT_NLG_KEY = "nlg"
 FINGERPRINT_RASA_VERSION_KEY = "version"
 FINGERPRINT_STORIES_KEY = "stories"
 FINGERPRINT_NLU_DATA_KEY = "messages"
 FINGERPRINT_TRAINED_AT_KEY = "trained_at"
 
 
+Section = namedtuple("Section", ["name", "relevant_keys"])
+
+SECTION_CORE = Section(
+    name="Core model",
+    relevant_keys=[
+        FINGERPRINT_CONFIG_KEY,
+        FINGERPRINT_CONFIG_CORE_KEY,
+        FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY,
+        FINGERPRINT_STORIES_KEY,
+        FINGERPRINT_RASA_VERSION_KEY,
+    ],
+)
+SECTION_NLU = Section(
+    name="NLU model",
+    relevant_keys=[
+        FINGERPRINT_CONFIG_KEY,
+        FINGERPRINT_CONFIG_NLU_KEY,
+        FINGERPRINT_NLU_DATA_KEY,
+        FINGERPRINT_RASA_VERSION_KEY,
+    ],
+)
+SECTION_NLG = Section(name="NLG Templates", relevant_keys=[FINGERPRINT_NLG_KEY])
+
+
+class FingerprintComparisonResult:
+    def __init__(
+        self,
+        nlu: bool = True,
+        core: bool = True,
+        nlg: bool = True,
+        force_trainining: bool = False,
+    ):
+        """Creates a `FingerprintComparisonResult` instance.
+
+        Args:
+            nlu: `True` if the NLU model should be retrained.
+            core: `True` if the Core model should be retrained.
+            nlg: `True` if the templates in the domain should be updated.
+            force_trainining: `True` if a training of all parts is forced.
+        """
+        self.nlu = nlu
+        self.core = core
+        self.nlg = nlg
+        self.force_training = force_trainining
+
+    def is_training_required(self) -> bool:
+        """Check if anything has to be retrained."""
+
+        return any([self.nlg, self.nlu, self.core, self.force_training])
+
+    def should_retrain_core(self) -> bool:
+        """Check if the Core model has to be updated."""
+
+        return self.force_training or self.core
+
+    def should_retrain_nlg(self) -> bool:
+        """Check if the templates have to be updated."""
+
+        return self.should_retrain_core() or self.nlg
+
+    def should_retrain_nlu(self) -> bool:
+        """Check if the NLU model has to be updated."""
+
+        return self.force_training or self.nlu
+
+
 def get_model(model_path: Text = DEFAULT_MODELS_PATH) -> TempDirectoryPath:
-    """Gets a model and unpacks it. Raises a `ModelNotFound` exception if
+    """Get a model and unpack it. Raises a `ModelNotFound` exception if
     no model could be found at the provided path.
 
     Args:
@@ -71,7 +144,7 @@ def get_model(model_path: Text = DEFAULT_MODELS_PATH) -> TempDirectoryPath:
 
 
 def get_latest_model(model_path: Text = DEFAULT_MODELS_PATH) -> Optional[Text]:
-    """Gets the latest model from a path.
+    """Get the latest model from a path.
 
     Args:
         model_path: Path to a directory containing zipped models.
@@ -92,9 +165,9 @@ def get_latest_model(model_path: Text = DEFAULT_MODELS_PATH) -> Optional[Text]:
 
 
 def unpack_model(
-    model_file: Text, working_directory: Optional[Text] = None
+    model_file: Text, working_directory: Optional[Union[Path, Text]] = None
 ) -> TempDirectoryPath:
-    """Unpacks a zipped Rasa model.
+    """Unpack a zipped Rasa model.
 
     Args:
         model_file: Path to zipped model.
@@ -113,7 +186,7 @@ def unpack_model(
     # All files are in a subdirectory.
     with tarfile.open(model_file, mode="r:gz") as tar:
         tar.extractall(working_directory)
-    logger.debug("Extracted model to '{}'.".format(working_directory))
+    logger.debug(f"Extracted model to '{working_directory}'.")
 
     return TempDirectoryPath(working_directory)
 
@@ -121,7 +194,7 @@ def unpack_model(
 def get_model_subdirectories(
     unpacked_model_path: Text
 ) -> Tuple[Optional[Text], Optional[Text]]:
-    """Returns paths for Core and NLU model directories, if they exist.
+    """Return paths for Core and NLU model directories, if they exist.
     If neither directories exist, a `ModelNotFound` exception is raised.
 
     Args:
@@ -132,7 +205,7 @@ def get_model_subdirectories(
                path to NLU subdirectory if it exists or `None` otherwise).
 
     """
-    core_path = os.path.join(unpacked_model_path, "core")
+    core_path = os.path.join(unpacked_model_path, DEFAULT_CORE_SUBDIRECTORY_NAME)
     nlu_path = os.path.join(unpacked_model_path, "nlu")
 
     if not os.path.isdir(core_path):
@@ -156,7 +229,7 @@ def create_package_rasa(
     output_filename: Text,
     fingerprint: Optional[Fingerprint] = None,
 ) -> Text:
-    """Creates a zipped Rasa model from trained model files.
+    """Create a zipped Rasa model from trained model files.
 
     Args:
         training_directory: Path to the directory which contains the trained
@@ -186,7 +259,7 @@ def create_package_rasa(
 
 
 async def model_fingerprint(file_importer: "TrainingDataImporter") -> Fingerprint:
-    """Creates a model fingerprint from its used configuration and training data.
+    """Create a model fingerprint from its used configuration and training data.
 
     Args:
         file_importer: File importer which provides the training data and model config.
@@ -195,6 +268,8 @@ async def model_fingerprint(file_importer: "TrainingDataImporter") -> Fingerprin
         The fingerprint.
 
     """
+    from rasa.core.domain import Domain
+
     import rasa
     import time
 
@@ -203,6 +278,10 @@ async def model_fingerprint(file_importer: "TrainingDataImporter") -> Fingerprin
     stories = await file_importer.get_stories()
     nlu_data = await file_importer.get_nlu_data()
 
+    domain_dict = domain.as_dict()
+    templates = domain_dict.pop("templates")
+    domain_without_nlg = Domain.from_dict(domain_dict)
+
     return {
         FINGERPRINT_CONFIG_KEY: _get_hash_of_config(
             config, exclude_keys=CONFIG_MANDATORY_KEYS
@@ -213,7 +292,8 @@ async def model_fingerprint(file_importer: "TrainingDataImporter") -> Fingerprin
         FINGERPRINT_CONFIG_NLU_KEY: _get_hash_of_config(
             config, include_keys=CONFIG_MANDATORY_KEYS_NLU
         ),
-        FINGERPRINT_DOMAIN_KEY: hash(domain),
+        FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY: hash(domain_without_nlg),
+        FINGERPRINT_NLG_KEY: get_dict_hash(templates),
         FINGERPRINT_NLU_DATA_KEY: hash(nlu_data),
         FINGERPRINT_STORIES_KEY: hash(stories),
         FINGERPRINT_TRAINED_AT_KEY: time.time(),
@@ -237,7 +317,7 @@ def _get_hash_of_config(
 
 
 def fingerprint_from_path(model_path: Text) -> Fingerprint:
-    """Loads a persisted fingerprint.
+    """Load a persisted fingerprint.
 
     Args:
         model_path: Path to directory containing the fingerprint.
@@ -257,7 +337,7 @@ def fingerprint_from_path(model_path: Text) -> Fingerprint:
 
 
 def persist_fingerprint(output_path: Text, fingerprint: Fingerprint):
-    """Persists a model fingerprint.
+    """Persist a model fingerprint.
 
     Args:
         output_path: Directory in which the fingerprint should be saved.
@@ -269,64 +349,19 @@ def persist_fingerprint(output_path: Text, fingerprint: Fingerprint):
     rasa.utils.io.dump_obj_as_json_to_file(path, fingerprint)
 
 
-def core_fingerprint_changed(
-    fingerprint1: Fingerprint, fingerprint2: Fingerprint
-) -> bool:
-    """Checks whether the fingerprints of the Core model changed.
-
-    Args:
-        fingerprint1: A fingerprint.
-        fingerprint2: Another fingerprint.
-
-    Returns:
-        `True` if the fingerprint for the Core model changed, else `False`.
-
-    """
-    relevant_keys = [
-        FINGERPRINT_CONFIG_KEY,
-        FINGERPRINT_CONFIG_CORE_KEY,
-        FINGERPRINT_DOMAIN_KEY,
-        FINGERPRINT_STORIES_KEY,
-        FINGERPRINT_RASA_VERSION_KEY,
-    ]
-
-    for k in relevant_keys:
-        if fingerprint1.get(k) != fingerprint2.get(k):
-            logger.info("Data ({}) for Core model changed.".format(k))
-            return True
-    return False
-
-
-def nlu_fingerprint_changed(
-    fingerprint1: Fingerprint, fingerprint2: Fingerprint
+def did_section_fingerprint_change(
+    fingerprint1: Fingerprint, fingerprint2: Fingerprint, section: Section
 ) -> bool:
-    """Checks whether the fingerprints of the NLU model changed.
-
-    Args:
-        fingerprint1: A fingerprint.
-        fingerprint2: Another fingerprint.
-
-    Returns:
-        `True` if the fingerprint for the NLU model changed, else `False`.
-
-    """
-
-    relevant_keys = [
-        FINGERPRINT_CONFIG_KEY,
-        FINGERPRINT_CONFIG_NLU_KEY,
-        FINGERPRINT_NLU_DATA_KEY,
-        FINGERPRINT_RASA_VERSION_KEY,
-    ]
-
-    for k in relevant_keys:
+    """Check whether the fingerprint of a section has changed."""
+    for k in section.relevant_keys:
         if fingerprint1.get(k) != fingerprint2.get(k):
-            logger.info("Data ({}) for NLU model changed.".format(k))
+            logger.info(f"Data ({k}) for {section.name} section changed.")
             return True
     return False
 
 
 def merge_model(source: Text, target: Text) -> bool:
-    """Merges two model directories.
+    """Merge two model directories.
 
     Args:
         source: The original folder which should be merged in another.
@@ -340,12 +375,14 @@ def merge_model(source: Text, target: Text) -> bool:
         shutil.move(source, target)
         return True
     except Exception as e:
-        logging.debug(e)
+        logging.debug(f"Could not merge model: {e}")
         return False
 
 
-def should_retrain(new_fingerprint: Fingerprint, old_model: Text, train_path: Text):
-    """Checks which component of a model should be retrained.
+def should_retrain(
+    new_fingerprint: Fingerprint, old_model: Text, train_path: Text
+) -> FingerprintComparisonResult:
+    """Check which components of a model should be retrained.
 
     Args:
         new_fingerprint: The fingerprint of the new model to be trained.
@@ -353,29 +390,45 @@ def should_retrain(new_fingerprint: Fingerprint, old_model: Text, train_path: Te
         train_path: Path to the directory in which the new model will be trained.
 
     Returns:
-        A tuple of boolean values indicating whether Rasa Core and/or Rasa NLU needs
+        A ShouldRetrain object indicating whether Rasa Core and/or Rasa NLU needs
         to be retrained or not.
 
     """
-    retrain_nlu = retrain_core = True
+    retrain = FingerprintComparisonResult()
 
     if old_model is None or not os.path.exists(old_model):
-        return retrain_core, retrain_nlu
+        return retrain
 
     with unpack_model(old_model) as unpacked:
         last_fingerprint = fingerprint_from_path(unpacked)
-
         old_core, old_nlu = get_model_subdirectories(unpacked)
 
-        if not core_fingerprint_changed(last_fingerprint, new_fingerprint):
-            target_path = os.path.join(train_path, "core")
-            retrain_core = not merge_model(old_core, target_path)
+        retrain = FingerprintComparisonResult(
+            core=did_section_fingerprint_change(
+                last_fingerprint, new_fingerprint, SECTION_CORE
+            ),
+            nlu=did_section_fingerprint_change(
+                last_fingerprint, new_fingerprint, SECTION_NLU
+            ),
+            nlg=did_section_fingerprint_change(
+                last_fingerprint, new_fingerprint, SECTION_NLG
+            ),
+        )
+
+        core_merge_failed = False
+        if not retrain.core:
+            target_path = os.path.join(train_path, DEFAULT_CORE_SUBDIRECTORY_NAME)
+            core_merge_failed = not merge_model(old_core, target_path)
+            retrain.core = core_merge_failed
+
+        if not retrain.nlg:
+            retrain.nlg = core_merge_failed
 
-        if not nlu_fingerprint_changed(last_fingerprint, new_fingerprint):
+        if not retrain.nlu:
             target_path = os.path.join(train_path, "nlu")
-            retrain_nlu = not merge_model(old_nlu, target_path)
+            retrain.nlu = not merge_model(old_nlu, target_path)
 
-        return retrain_core, retrain_nlu
+        return retrain
 
 
 def package_model(
@@ -386,7 +439,7 @@ def package_model(
     model_prefix: Text = "",
 ):
     """
-    Compresses a trained model.
+    Compress a trained model.
 
     Args:
         fingerprint: fingerprint of the model
@@ -409,3 +462,19 @@ def package_model(
     )
 
     return output_directory
+
+
+async def update_model_with_new_domain(
+    importer: "TrainingDataImporter", unpacked_model_path: Union[Path, Text]
+) -> None:
+    """Overwrites the domain of an unpacked model with a new domain.
+
+    Args:
+        importer: Importer which provides the new domain.
+        unpacked_model_path: Path to the unpacked model.
+    """
+
+    model_path = Path(unpacked_model_path) / DEFAULT_CORE_SUBDIRECTORY_NAME
+    domain = await importer.get_domain()
+
+    domain.persist(model_path / DEFAULT_DOMAIN_PATH)
diff --git a/rasa/train.py b/rasa/train.py
index a512ba41a23e..23fbe729a62a 100644
--- a/rasa/train.py
+++ b/rasa/train.py
@@ -6,6 +6,7 @@
 
 from rasa.importers.importer import TrainingDataImporter
 from rasa import model
+from rasa.model import FingerprintComparisonResult
 from rasa.core.domain import Domain
 from rasa.utils.common import TempDirectoryPath
 
@@ -16,7 +17,7 @@
     bcolors,
     print_color,
 )
-from rasa.constants import DEFAULT_MODELS_PATH
+from rasa.constants import DEFAULT_MODELS_PATH, DEFAULT_CORE_SUBDIRECTORY_NAME
 
 
 def train(
@@ -134,8 +135,6 @@ async def _train_async_internal(
     Returns:
         Path of the trained model archive.
     """
-    new_fingerprint = await model.model_fingerprint(file_importer)
-
     stories = await file_importer.get_stories()
     nlu_data = await file_importer.get_nlu_data()
 
@@ -164,19 +163,22 @@ async def _train_async_internal(
             kwargs=kwargs,
         )
 
+    new_fingerprint = await model.model_fingerprint(file_importer)
     old_model = model.get_latest_model(output_path)
-    retrain_core, retrain_nlu = model.should_retrain(
-        new_fingerprint, old_model, train_path
+    fingerprint_comparison = FingerprintComparisonResult(
+        force_trainining=force_training
     )
+    if not force_training:
+        fingerprint_comparison = model.should_retrain(
+            new_fingerprint, old_model, train_path
+        )
 
-    if force_training or retrain_core or retrain_nlu:
+    if fingerprint_comparison.is_training_required():
         await _do_training(
             file_importer,
             output_path=output_path,
             train_path=train_path,
-            force_training=force_training,
-            retrain_core=retrain_core,
-            retrain_nlu=retrain_nlu,
+            fingerprint_comparison_result=fingerprint_comparison,
             fixed_model_name=fixed_model_name,
             persist_nlu_training_data=persist_nlu_training_data,
             kwargs=kwargs,
@@ -200,15 +202,15 @@ async def _do_training(
     file_importer: TrainingDataImporter,
     output_path: Text,
     train_path: Text,
-    force_training: bool = False,
-    retrain_core: bool = True,
-    retrain_nlu: bool = True,
+    fingerprint_comparison_result: Optional[FingerprintComparisonResult] = None,
     fixed_model_name: Optional[Text] = None,
     persist_nlu_training_data: bool = False,
     kwargs: Optional[Dict] = None,
 ):
+    if not fingerprint_comparison_result:
+        fingerprint_comparison_result = FingerprintComparisonResult()
 
-    if force_training or retrain_core:
+    if fingerprint_comparison_result.should_retrain_core():
         await _train_core_with_validated_data(
             file_importer,
             output=output_path,
@@ -216,13 +218,21 @@ async def _do_training(
             fixed_model_name=fixed_model_name,
             kwargs=kwargs,
         )
+    elif fingerprint_comparison_result.should_retrain_nlg():
+        print_color(
+            "Core stories/configuration did not change. "
+            "Only the templates section has been changed. A new model with "
+            "the updated templates will be created.",
+            color=bcolors.OKBLUE,
+        )
+        await model.update_model_with_new_domain(file_importer, train_path)
     else:
         print_color(
             "Core stories/configuration did not change. No need to retrain Core model.",
             color=bcolors.OKBLUE,
         )
 
-    if force_training or retrain_nlu:
+    if fingerprint_comparison_result.should_retrain_nlu():
         await _train_nlu_with_validated_data(
             file_importer,
             output=output_path,
@@ -341,7 +351,7 @@ async def _train_core_with_validated_data(
         await rasa.core.train(
             domain_file=domain,
             training_resource=file_importer,
-            output_path=os.path.join(_train_path, "core"),
+            output_path=os.path.join(_train_path, DEFAULT_CORE_SUBDIRECTORY_NAME),
             policy_config=config,
             kwargs=kwargs,
         )
diff --git a/tests/core/test_model.py b/tests/core/test_model.py
index 40824f596984..11216f8a7c4f 100644
--- a/tests/core/test_model.py
+++ b/tests/core/test_model.py
@@ -2,7 +2,9 @@
 import tempfile
 import time
 import shutil
-from typing import Text, Optional
+from pathlib import Path
+from typing import Text, Optional, Any
+from unittest.mock import Mock
 
 import pytest
 from _pytest.tmpdir import TempdirFactory
@@ -11,27 +13,37 @@
 import rasa.core
 import rasa.nlu
 from rasa.importers.rasa import RasaFileImporter
-from rasa.constants import DEFAULT_CONFIG_PATH, DEFAULT_DATA_PATH
+from rasa.constants import (
+    DEFAULT_CONFIG_PATH,
+    DEFAULT_DATA_PATH,
+    DEFAULT_DOMAIN_PATH,
+    DEFAULT_CORE_SUBDIRECTORY_NAME,
+)
 from rasa.core.domain import Domain
+from rasa.core.utils import get_dict_hash
+from rasa import model
 from rasa.model import (
     FINGERPRINT_CONFIG_KEY,
-    FINGERPRINT_DOMAIN_KEY,
+    FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY,
+    FINGERPRINT_NLG_KEY,
     FINGERPRINT_FILE_PATH,
     FINGERPRINT_NLU_DATA_KEY,
     FINGERPRINT_RASA_VERSION_KEY,
     FINGERPRINT_STORIES_KEY,
     FINGERPRINT_TRAINED_AT_KEY,
-    core_fingerprint_changed,
+    FINGERPRINT_CONFIG_CORE_KEY,
+    FINGERPRINT_CONFIG_NLU_KEY,
+    SECTION_CORE,
+    SECTION_NLU,
     create_package_rasa,
     get_latest_model,
     get_model,
     get_model_subdirectories,
     model_fingerprint,
-    nlu_fingerprint_changed,
     Fingerprint,
+    did_section_fingerprint_change,
     should_retrain,
-    FINGERPRINT_CONFIG_CORE_KEY,
-    FINGERPRINT_CONFIG_NLU_KEY,
+    FingerprintComparisonResult,
 )
 from rasa.exceptions import ModelNotFound
 
@@ -50,7 +62,7 @@ def test_get_latest_model(trained_model):
 def test_get_model_from_directory(trained_model):
     unpacked = get_model(trained_model)
 
-    assert os.path.exists(os.path.join(unpacked, "core"))
+    assert os.path.exists(os.path.join(unpacked, DEFAULT_CORE_SUBDIRECTORY_NAME))
     assert os.path.exists(os.path.join(unpacked, "nlu"))
 
 
@@ -83,7 +95,7 @@ def test_get_model_from_directory_with_subdirectories(
 
 def test_get_model_from_directory_nlu_only(trained_model):
     unpacked = get_model(trained_model)
-    shutil.rmtree(os.path.join(unpacked, "core"))
+    shutil.rmtree(os.path.join(unpacked, DEFAULT_CORE_SUBDIRECTORY_NAME))
     unpacked_core, unpacked_nlu = get_model_subdirectories(unpacked)
 
     assert not unpacked_core
@@ -91,13 +103,14 @@ def test_get_model_from_directory_nlu_only(trained_model):
 
 
 def _fingerprint(
-    config: Optional[Text] = None,
-    config_nlu: Optional[Text] = None,
-    config_core: Optional[Text] = None,
-    domain: Optional[int] = None,
+    config: Optional[Any] = None,
+    config_nlu: Optional[Any] = None,
+    config_core: Optional[Any] = None,
+    domain: Optional[Any] = None,
+    nlg: Optional[Any] = None,
+    stories: Optional[Any] = None,
+    nlu: Optional[Any] = None,
     rasa_version: Text = "1.0",
-    stories: Optional[int] = None,
-    nlu: Optional[int] = None,
 ):
     return {
         FINGERPRINT_CONFIG_KEY: config if config is not None else ["test"],
@@ -105,7 +118,8 @@ def _fingerprint(
         if config_core is not None
         else ["test"],
         FINGERPRINT_CONFIG_NLU_KEY: config_nlu if config_nlu is not None else ["test"],
-        FINGERPRINT_DOMAIN_KEY: domain if domain is not None else ["test"],
+        FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY: domain if domain is not None else ["test"],
+        FINGERPRINT_NLG_KEY: nlg if nlg is not None else ["test"],
         FINGERPRINT_TRAINED_AT_KEY: time.time(),
         FINGERPRINT_RASA_VERSION_KEY: rasa_version,
         FINGERPRINT_STORIES_KEY: stories if stories is not None else ["test"],
@@ -126,39 +140,52 @@ def test_persist_and_load_fingerprint():
 
 
 @pytest.mark.parametrize(
-    "fingerprint2",
+    "fingerprint2, changed",
     [
-        _fingerprint(config=["other"]),
-        _fingerprint(domain=["other"]),
-        _fingerprint(domain=Domain.empty()),
-        _fingerprint(stories=["test", "other"]),
-        _fingerprint(rasa_version="100"),
-        _fingerprint(config=["other"], domain=["other"]),
+        (_fingerprint(config=["other"]), True),
+        (_fingerprint(config_core=["other"]), True),
+        (_fingerprint(domain=["other"]), True),
+        (_fingerprint(domain=Domain.empty()), True),
+        (_fingerprint(stories=["test", "other"]), True),
+        (_fingerprint(rasa_version="100"), True),
+        (_fingerprint(config=["other"], domain=["other"]), True),
+        (_fingerprint(nlg=["other"]), False),
+        (_fingerprint(nlu=["test", "other"]), False),
+        (_fingerprint(config_nlu=["other"]), False),
     ],
 )
-def test_core_fingerprint_changed(fingerprint2):
+def test_core_fingerprint_changed(fingerprint2, changed):
     fingerprint1 = _fingerprint()
-    assert core_fingerprint_changed(fingerprint1, fingerprint2)
+    assert (
+        did_section_fingerprint_change(fingerprint1, fingerprint2, SECTION_CORE)
+        is changed
+    )
 
 
 @pytest.mark.parametrize(
-    "fingerprint2",
+    "fingerprint2, changed",
     [
-        _fingerprint(config=["other"]),
-        _fingerprint(nlu=["test", "other"]),
-        _fingerprint(rasa_version="100"),
-        _fingerprint(rasa_version="100", config=["other"]),
+        (_fingerprint(config=["other"]), True),
+        (_fingerprint(nlu=["test", "other"]), True),
+        (_fingerprint(rasa_version="100"), True),
+        (_fingerprint(rasa_version="100", config=["other"]), True),
+        (_fingerprint(nlg=["other"]), False),
+        (_fingerprint(config_core=["other"]), False),
+        (_fingerprint(stories=["other"]), False),
     ],
 )
-def test_nlu_fingerprint_changed(fingerprint2):
+def test_nlu_fingerprint_changed(fingerprint2, changed):
     fingerprint1 = _fingerprint()
-    assert nlu_fingerprint_changed(fingerprint1, fingerprint2)
+    assert (
+        did_section_fingerprint_change(fingerprint1, fingerprint2, SECTION_NLU)
+        is changed
+    )
 
 
 def _project_files(
     project,
     config_file=DEFAULT_CONFIG_PATH,
-    domain="domain.yml",
+    domain=DEFAULT_DOMAIN_PATH,
     training_files=DEFAULT_DATA_PATH,
 ):
     paths = {
@@ -192,9 +219,10 @@ async def test_create_fingerprint_from_invalid_paths(project, project_files):
         config_nlu="",
         config_core="",
         domain=hash(Domain.empty()),
-        rasa_version=rasa.__version__,
+        nlg=get_dict_hash(Domain.empty().templates),
         stories=hash(StoryGraph([])),
         nlu=hash(TrainingData()),
+        rasa_version=rasa.__version__,
     )
 
     actual = await model_fingerprint(project_files)
@@ -226,7 +254,7 @@ async def test_rasa_packaging(trained_model, project, use_fingerprint):
     assert (
         os.path.exists(os.path.join(unpacked, FINGERPRINT_FILE_PATH)) == use_fingerprint
     )
-    assert os.path.exists(os.path.join(unpacked, "core"))
+    assert os.path.exists(os.path.join(unpacked, DEFAULT_CORE_SUBDIRECTORY_NAME))
     assert os.path.exists(os.path.join(unpacked, "nlu"))
 
     assert not os.path.exists(unpacked_model_path)
@@ -240,60 +268,66 @@ async def test_rasa_packaging(trained_model, project, use_fingerprint):
             "old": _fingerprint(stories=["others"]),
             "retrain_core": True,
             "retrain_nlu": False,
+            "retrain_nlg": True,
         },
         {
             "new": _fingerprint(nlu=["others"]),
             "old": _fingerprint(),
             "retrain_core": False,
             "retrain_nlu": True,
+            "retrain_nlg": False,
         },
         {
             "new": _fingerprint(config="others"),
             "old": _fingerprint(),
             "retrain_core": True,
             "retrain_nlu": True,
+            "retrain_nlg": True,
         },
         {
             "new": _fingerprint(config_core="others"),
             "old": _fingerprint(),
             "retrain_core": True,
             "retrain_nlu": False,
+            "retrain_nlg": True,
         },
         {
             "new": _fingerprint(),
             "old": _fingerprint(config_nlu="others"),
             "retrain_core": False,
             "retrain_nlu": True,
+            "retrain_nlg": False,
         },
         {
             "new": _fingerprint(),
             "old": _fingerprint(),
             "retrain_core": False,
             "retrain_nlu": False,
+            "retrain_nlg": False,
+        },
+        {
+            "new": _fingerprint(),
+            "old": _fingerprint(nlg=["others"]),
+            "retrain_core": False,
+            "retrain_nlu": False,
+            "retrain_nlg": True,
         },
     ],
 )
-def test_should_retrain(trained_model, fingerprint):
+def test_should_retrain(trained_model: Text, fingerprint: Fingerprint):
     old_model = set_fingerprint(trained_model, fingerprint["old"])
 
-    retrain_core, retrain_nlu = should_retrain(
-        fingerprint["new"], old_model, tempfile.mkdtemp()
-    )
+    retrain = should_retrain(fingerprint["new"], old_model, tempfile.mkdtemp())
 
-    assert retrain_core == fingerprint["retrain_core"]
-    assert retrain_nlu == fingerprint["retrain_nlu"]
+    assert retrain.should_retrain_core() == fingerprint["retrain_core"]
+    assert retrain.should_retrain_nlg() == fingerprint["retrain_nlg"]
+    assert retrain.should_retrain_nlu() == fingerprint["retrain_nlu"]
 
 
-def set_fingerprint(
-    trained_model: Text, fingerprint: Fingerprint, use_fingerprint: bool = True
-) -> Text:
+def set_fingerprint(trained_model: Text, fingerprint: Fingerprint) -> Text:
     unpacked_model_path = get_model(trained_model)
 
     os.remove(os.path.join(unpacked_model_path, FINGERPRINT_FILE_PATH))
-    if use_fingerprint:
-        fingerprint = fingerprint
-    else:
-        fingerprint = None
 
     tempdir = tempfile.mkdtemp()
     output_path = os.path.join(tempdir, "test.tar.gz")
@@ -301,3 +335,62 @@ def set_fingerprint(
     create_package_rasa(unpacked_model_path, output_path, fingerprint)
 
     return output_path
+
+
+@pytest.mark.parametrize(
+    "comparison_result,retrain_all,retrain_core,retrain_nlg,retrain_nlu",
+    [
+        (FingerprintComparisonResult(force_trainining=True), True, True, True, True),
+        (
+            FingerprintComparisonResult(core=True, nlu=False, nlg=False),
+            True,
+            True,
+            True,
+            False,
+        ),
+        (
+            FingerprintComparisonResult(core=False, nlu=True, nlg=False),
+            True,
+            False,
+            False,
+            True,
+        ),
+        (
+            FingerprintComparisonResult(core=True, nlu=True, nlg=False),
+            True,
+            True,
+            True,
+            True,
+        ),
+    ],
+)
+def test_fingerprint_comparison_result(
+    comparison_result: FingerprintComparisonResult,
+    retrain_all: bool,
+    retrain_core: bool,
+    retrain_nlg: bool,
+    retrain_nlu: bool,
+):
+    assert comparison_result.is_training_required() == retrain_all
+    assert comparison_result.should_retrain_core() == retrain_core
+    assert comparison_result.should_retrain_nlg() == retrain_nlg
+    assert comparison_result.should_retrain_nlu() == retrain_nlu
+
+
+async def test_update_with_new_domain(trained_model: Text, tmpdir: Path):
+    _ = model.unpack_model(trained_model, tmpdir)
+
+    new_domain = Domain.empty()
+
+    mocked_importer = Mock()
+
+    async def get_domain() -> Domain:
+        return new_domain
+
+    mocked_importer.get_domain = get_domain
+
+    await model.update_model_with_new_domain(mocked_importer, tmpdir)
+
+    actual = Domain.load(tmpdir / DEFAULT_CORE_SUBDIRECTORY_NAME / DEFAULT_DOMAIN_PATH)
+
+    assert actual.is_empty()
diff --git a/tests/importers/test_multi_project.py b/tests/importers/test_multi_project.py
index df37c794ad51..7fc223188178 100644
--- a/tests/importers/test_multi_project.py
+++ b/tests/importers/test_multi_project.py
@@ -4,12 +4,12 @@
 from _pytest.tmpdir import TempdirFactory
 import os
 
+from rasa.constants import DEFAULT_CORE_SUBDIRECTORY_NAME, DEFAULT_DOMAIN_PATH
 from rasa.nlu.training_data.formats import RasaReader
 from rasa import model
 from rasa.core import utils
 from rasa.core.domain import Domain
 from rasa.importers.multi_project import MultiProjectImporter
-from rasa.train import train_async
 
 
 def test_load_imports_from_directory_tree(tmpdir_factory: TempdirFactory):
@@ -242,7 +242,9 @@ async def test_multi_project_training(trained_async):
 
     unpacked = model.unpack_model(trained_stack_model_path)
 
-    domain_file = os.path.join(unpacked, "core", "domain.yml")
+    domain_file = os.path.join(
+        unpacked, DEFAULT_CORE_SUBDIRECTORY_NAME, DEFAULT_DOMAIN_PATH
+    )
     domain = Domain.load(domain_file)
 
     expected_intents = {


Code review comments:
diff_hunk: @@ -340,42 +375,60 @@ def merge_model(source: Text, target: Text) -> bool:
         shutil.move(source, target)
         return True
     except Exception as e:
-        logging.debug(e)
+        logging.debug(f"Could not merge model: {e}")
         return False
 
 
-def should_retrain(new_fingerprint: Fingerprint, old_model: Text, train_path: Text):
-    """Checks which component of a model should be retrained.
+def should_retrain(
+    new_fingerprint: Fingerprint, old_model: Text, train_path: Text
+) -> FingerprintComparisonResult:
+    """Check which components of a model should be retrained.
 
     Args:
         new_fingerprint: The fingerprint of the new model to be trained.
         old_model: Path to the old zipped model file.
         train_path: Path to the directory in which the new model will be trained.
 
     Returns:
-        A tuple of boolean values indicating whether Rasa Core and/or Rasa NLU needs
+        A ShouldRetrain object indicating whether Rasa Core and/or Rasa NLU needs
         to be retrained or not.
 
     """
-    retrain_nlu = retrain_core = True
+    retrain = FingerprintComparisonResult()
 
     if old_model is None or not os.path.exists(old_model):
-        return retrain_core, retrain_nlu
+        return retrain
 
     with unpack_model(old_model) as unpacked:
         last_fingerprint = fingerprint_from_path(unpacked)
-
         old_core, old_nlu = get_model_subdirectories(unpacked)
 
-        if not core_fingerprint_changed(last_fingerprint, new_fingerprint):
-            target_path = os.path.join(train_path, "core")
-            retrain_core = not merge_model(old_core, target_path)
+        retrain = FingerprintComparisonResult(
+            core=did_section_fingerprint_change(
+                last_fingerprint, new_fingerprint, SECTION_CORE
+            ),
+            nlu=did_section_fingerprint_change(
+                last_fingerprint, new_fingerprint, SECTION_NLU
+            ),
+            nlg=did_section_fingerprint_change(
+                last_fingerprint, new_fingerprint, SECTION_NLG
+            ),
+        )
+
+        core_merge_failed = False
+        if not retrain.core:
+            target_path = os.path.join(train_path, DEFAULT_CORE_SUBDIRECTORY_NAME)
+            core_merge_failed = not merge_model(old_core, target_path)
+            retrain.core = core_merge_failed
+
+        if not retrain.nlg:
+            retrain.nlg = core_merge_failed
path: rasa/model.py
text: @user1:
Can you add a comment here why we overwrite `retrain.nlg` with `core_merge_failed`? 

@user2:
also changed the code to make it a bit easier to read

diff_hunk: @@ -22,25 +26,94 @@
 if typing.TYPE_CHECKING:
     from rasa.importers.importer import TrainingDataImporter
 
-# Type alias for the fingerprint
-Fingerprint = Dict[Text, Union[Text, List[Text], int, float]]
 
 logger = logging.getLogger(__name__)
 
+
+# Type alias for the fingerprint
+Fingerprint = Dict[Text, Union[Text, List[Text], int, float]]
+
 FINGERPRINT_FILE_PATH = "fingerprint.json"
 
 FINGERPRINT_CONFIG_KEY = "config"
 FINGERPRINT_CONFIG_CORE_KEY = "core-config"
 FINGERPRINT_CONFIG_NLU_KEY = "nlu-config"
-FINGERPRINT_DOMAIN_KEY = "domain"
+FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY = "domain"
+FINGERPRINT_NLG_KEY = "nlg"
 FINGERPRINT_RASA_VERSION_KEY = "version"
 FINGERPRINT_STORIES_KEY = "stories"
 FINGERPRINT_NLU_DATA_KEY = "messages"
 FINGERPRINT_TRAINED_AT_KEY = "trained_at"
 
 
+Section = namedtuple("Section", ["name", "relevant_keys"])
+
+SECTION_CORE = Section(
+    name="Core model",
+    relevant_keys=[
+        FINGERPRINT_CONFIG_KEY,
+        FINGERPRINT_CONFIG_CORE_KEY,
+        FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY,
+        FINGERPRINT_STORIES_KEY,
+        FINGERPRINT_RASA_VERSION_KEY,
+    ],
+)
+SECTION_NLU = Section(
+    name="NLU model",
+    relevant_keys=[
+        FINGERPRINT_CONFIG_KEY,
+        FINGERPRINT_CONFIG_NLU_KEY,
+        FINGERPRINT_NLU_DATA_KEY,
+        FINGERPRINT_RASA_VERSION_KEY,
+    ],
+)
+SECTION_NLG = Section(name="NLG Templates", relevant_keys=[FINGERPRINT_NLG_KEY])
+
+
+class FingerprintComparisonResult:
+    def __init__(
+        self,
+        nlu: bool = True,
+        core: bool = True,
+        nlg: bool = True,
+        force_trainining: bool = False,
path: rasa/model.py
text: @user1:
```suggestion
        force_training: bool = False,
```

diff_hunk: @@ -340,42 +375,60 @@ def merge_model(source: Text, target: Text) -> bool:
         shutil.move(source, target)
         return True
     except Exception as e:
-        logging.debug(e)
+        logging.debug(f"Could not merge model: {e}")
         return False
 
 
-def should_retrain(new_fingerprint: Fingerprint, old_model: Text, train_path: Text):
-    """Checks which component of a model should be retrained.
+def should_retrain(
+    new_fingerprint: Fingerprint, old_model: Text, train_path: Text
+) -> FingerprintComparisonResult:
+    """Check which components of a model should be retrained.
 
     Args:
         new_fingerprint: The fingerprint of the new model to be trained.
         old_model: Path to the old zipped model file.
         train_path: Path to the directory in which the new model will be trained.
 
     Returns:
-        A tuple of boolean values indicating whether Rasa Core and/or Rasa NLU needs
+        A ShouldRetrain object indicating whether Rasa Core and/or Rasa NLU needs
path: rasa/model.py
text: @user1:
```suggestion
        A FingerprintComparisonResult object indicating whether Rasa Core and/or Rasa NLU needs
```

diff_hunk: @@ -22,25 +26,94 @@
 if typing.TYPE_CHECKING:
     from rasa.importers.importer import TrainingDataImporter
 
-# Type alias for the fingerprint
-Fingerprint = Dict[Text, Union[Text, List[Text], int, float]]
 
 logger = logging.getLogger(__name__)
 
+
+# Type alias for the fingerprint
+Fingerprint = Dict[Text, Union[Text, List[Text], int, float]]
+
 FINGERPRINT_FILE_PATH = "fingerprint.json"
 
 FINGERPRINT_CONFIG_KEY = "config"
 FINGERPRINT_CONFIG_CORE_KEY = "core-config"
 FINGERPRINT_CONFIG_NLU_KEY = "nlu-config"
-FINGERPRINT_DOMAIN_KEY = "domain"
+FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY = "domain"
+FINGERPRINT_NLG_KEY = "nlg"
 FINGERPRINT_RASA_VERSION_KEY = "version"
 FINGERPRINT_STORIES_KEY = "stories"
 FINGERPRINT_NLU_DATA_KEY = "messages"
 FINGERPRINT_TRAINED_AT_KEY = "trained_at"
 
 
+Section = namedtuple("Section", ["name", "relevant_keys"])
+
+SECTION_CORE = Section(
+    name="Core model",
+    relevant_keys=[
+        FINGERPRINT_CONFIG_KEY,
+        FINGERPRINT_CONFIG_CORE_KEY,
+        FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY,
+        FINGERPRINT_STORIES_KEY,
+        FINGERPRINT_RASA_VERSION_KEY,
+    ],
+)
+SECTION_NLU = Section(
+    name="NLU model",
+    relevant_keys=[
+        FINGERPRINT_CONFIG_KEY,
+        FINGERPRINT_CONFIG_NLU_KEY,
+        FINGERPRINT_NLU_DATA_KEY,
+        FINGERPRINT_RASA_VERSION_KEY,
+    ],
+)
+SECTION_NLG = Section(name="NLG Templates", relevant_keys=[FINGERPRINT_NLG_KEY])
path: rasa/model.py
text: @user1:
```suggestion
SECTION_NLG = Section(name="NLG templates", relevant_keys=[FINGERPRINT_NLG_KEY])
```

diff_hunk: @@ -340,42 +375,60 @@ def merge_model(source: Text, target: Text) -> bool:
         shutil.move(source, target)
         return True
     except Exception as e:
-        logging.debug(e)
+        logging.debug(f"Could not merge model: {e}")
         return False
 
 
-def should_retrain(new_fingerprint: Fingerprint, old_model: Text, train_path: Text):
-    """Checks which component of a model should be retrained.
+def should_retrain(
+    new_fingerprint: Fingerprint, old_model: Text, train_path: Text
+) -> FingerprintComparisonResult:
+    """Check which components of a model should be retrained.
 
     Args:
         new_fingerprint: The fingerprint of the new model to be trained.
         old_model: Path to the old zipped model file.
         train_path: Path to the directory in which the new model will be trained.
 
     Returns:
-        A tuple of boolean values indicating whether Rasa Core and/or Rasa NLU needs
+        A ShouldRetrain object indicating whether Rasa Core and/or Rasa NLU needs
         to be retrained or not.
 
     """
-    retrain_nlu = retrain_core = True
+    retrain = FingerprintComparisonResult()
 
     if old_model is None or not os.path.exists(old_model):
-        return retrain_core, retrain_nlu
+        return retrain
 
     with unpack_model(old_model) as unpacked:
         last_fingerprint = fingerprint_from_path(unpacked)
-
         old_core, old_nlu = get_model_subdirectories(unpacked)
 
-        if not core_fingerprint_changed(last_fingerprint, new_fingerprint):
-            target_path = os.path.join(train_path, "core")
-            retrain_core = not merge_model(old_core, target_path)
+        retrain = FingerprintComparisonResult(
+            core=did_section_fingerprint_change(
+                last_fingerprint, new_fingerprint, SECTION_CORE
+            ),
+            nlu=did_section_fingerprint_change(
+                last_fingerprint, new_fingerprint, SECTION_NLU
+            ),
+            nlg=did_section_fingerprint_change(
+                last_fingerprint, new_fingerprint, SECTION_NLG
+            ),
+        )
+
+        core_merge_failed = False
+        if not retrain.core:
path: rasa/model.py
text: @user1:
why are we not calling the functions here? e.g. `should_retrain_core()`?

diff_hunk: @@ -22,25 +26,94 @@
 if typing.TYPE_CHECKING:
     from rasa.importers.importer import TrainingDataImporter
 
-# Type alias for the fingerprint
-Fingerprint = Dict[Text, Union[Text, List[Text], int, float]]
 
 logger = logging.getLogger(__name__)
 
+
+# Type alias for the fingerprint
+Fingerprint = Dict[Text, Union[Text, List[Text], int, float]]
+
 FINGERPRINT_FILE_PATH = "fingerprint.json"
 
 FINGERPRINT_CONFIG_KEY = "config"
 FINGERPRINT_CONFIG_CORE_KEY = "core-config"
 FINGERPRINT_CONFIG_NLU_KEY = "nlu-config"
-FINGERPRINT_DOMAIN_KEY = "domain"
+FINGERPRINT_DOMAIN_WITHOUT_NLG_KEY = "domain"
+FINGERPRINT_NLG_KEY = "nlg"
 FINGERPRINT_RASA_VERSION_KEY = "version"
 FINGERPRINT_STORIES_KEY = "stories"
 FINGERPRINT_NLU_DATA_KEY = "messages"
 FINGERPRINT_TRAINED_AT_KEY = "trained_at"
 
 
+Section = namedtuple("Section", ["name", "relevant_keys"])
path: rasa/model.py
text: @user1:
Why not using the new syntax?
```
class Section(NamedTuple):
    name: Text
    relevant_keys: List[Text]
```