Repository: dimagi/open-chat-studio
Pull request #1612: Ba/llm node tag
Author: login: barora05
Created: 2025-05-21T05:43:52Z | Merged: 2025-05-22T15:01:55Z

## Description
Tag output message for LLM node. Closes #1143.

## User Impact
<!-- Describe the impact of this change on the end-users. -->

### Demo
<!-- If relevent, include screenshots or a loom video to demonstrate the new behavior
**Include step-by-step instructions to enable functionality of the change-->

### Docs and Changelog
<!--Link to documentation that has been updated.-->


Linked issue #1143: [design] Allow nodes in a pipeline to create an OCS tag
# Task
1. Add a checkbox option to all nodes: "Tag output message"
    - This should have the following help text: "Add a [tag](https://docs.openchatstudio.com/concepts/tags/) to the generated message with this node's name."
2. For router nodes, both llm and static, add an additional checkbox: "Tag output message with route". The help text for this should be similar to above: "Add a tag to the generated message with the route keyword selected by this node."


## Original notes

some more context [here](https://dimagi.slack.com/archives/C05PK63Q89H/p1738698576222059)

I can imagine this working in a number of ways, including:
* Allowing LLM routers to save a route as a tag
* Allowing individual LLM nodes to tag a message with their node name
* Allowing any node to create add an OCS tag to the current pipeline execution?

I'm not sure what's best (and imagine there are others). I think the router saving routes as a tag would replicate the previous functionality 
Linked issue comments: user: login: barora05
created_at: 2025-04-28T10:53:04Z
body: Second part of this ticket has been completed. What needs to be done for the first part @snopoke ?
author_association: CONTRIBUTOR
user: login: snopoke
created_at: 2025-04-29T07:28:53Z
body: @barora05 for the first part the task is to add text input where the user can type a tag value. If the value is present then a tag will be added to the output message (with a blank tag category). 
author_association: CONTRIBUTOR

Diff:
diff --git a/apps/chat/bots.py b/apps/chat/bots.py
index decd3ca1c9..c8acc16211 100644
--- a/apps/chat/bots.py
+++ b/apps/chat/bots.py
@@ -223,7 +223,7 @@ def _get_safe_response(self, safety_layer: SafetyLayer):
             self.generator_chain = self.chain
 
         if self.generator_chain and self.generator_chain.history_manager.ai_message:
-            self.generator_chain.history_manager.ai_message.add_system_tag(
+            self.generator_chain.history_manager.ai_message.create_and_add_tag(
                 safety_layer.name, tag_category=TagCategories.SAFETY_LAYER_RESPONSE
             )
         return self.generator_chain.history_manager.ai_message
diff --git a/apps/chat/channels.py b/apps/chat/channels.py
index ebe9ef5bed..e3e6007ad5 100644
--- a/apps/chat/channels.py
+++ b/apps/chat/channels.py
@@ -708,7 +708,7 @@ def _unsupported_message_type_response(self) -> str:
         chat_message = ChatMessage.objects.create(
             chat=self.experiment_session.chat, message_type=ChatMessageType.AI, content=self.message.message_text
         )
-        chat_message.add_system_tag("unsupported_message_type", TagCategories.ERROR)
+        chat_message.create_and_add_tag("unsupported_message_type", TagCategories.ERROR)
         return EventBot(self.experiment_session, self.experiment, trace_info, history_manager).get_user_message(
             UNSUPPORTED_MESSAGE_BOT_PROMPT.format(supported_types=self.supported_message_types)
         )
diff --git a/apps/chat/models.py b/apps/chat/models.py
index 31d873debd..9314b67749 100644
--- a/apps/chat/models.py
+++ b/apps/chat/models.py
@@ -230,11 +230,11 @@ def get_attached_files(self):
     def get_metadata(self, key: str):
         return self.metadata.get(key, None)
 
-    def add_system_tag(self, tag: str, tag_category: TagCategories):
+    def create_and_add_tag(self, tag: str, tag_category: TagCategories):
         tag, _ = Tag.objects.get_or_create(
             name=tag,
             team=self.chat.team,
-            is_system_tag=True,
+            is_system_tag=bool(tag_category),
             category=tag_category,
         )
         self.add_tag(tag, team=self.chat.team, added_by=None)
@@ -243,7 +243,7 @@ def add_version_tag(self, version_number: int, is_a_version: bool):
         tag = f"v{version_number}"
         if not is_a_version:
             tag = f"{tag}-unreleased"
-        self.add_system_tag(tag=tag, tag_category=TagCategories.EXPERIMENT_VERSION)
+        self.create_and_add_tag(tag=tag, tag_category=TagCategories.EXPERIMENT_VERSION)
 
     def add_rating(self, tag: str):
         tag, _ = Tag.objects.get_or_create(
diff --git a/apps/chat/tests/test_models.py b/apps/chat/tests/test_models.py
index bc7fa7e985..502b83b482 100644
--- a/apps/chat/tests/test_models.py
+++ b/apps/chat/tests/test_models.py
@@ -44,7 +44,7 @@ def test_get_processor_bot_tag_name(self):
         ai_message_with_tag = ChatMessage.objects.create(
             chat=session.chat, message_type=ChatMessageType.AI, content="Hi"
         )
-        ai_message_with_tag.add_system_tag(tag="some-bot", tag_category=TagCategories.BOT_RESPONSE)
+        ai_message_with_tag.create_and_add_tag(tag="some-bot", tag_category=TagCategories.BOT_RESPONSE)
 
         assert human_message.get_processor_bot_tag_name() is None
         assert ai_message_wo_tag.get_processor_bot_tag_name() is None
diff --git a/apps/experiments/tests/test_models.py b/apps/experiments/tests/test_models.py
index 934313a48c..e887a392fa 100644
--- a/apps/experiments/tests/test_models.py
+++ b/apps/experiments/tests/test_models.py
@@ -416,7 +416,7 @@ def test_experiment_version_for_display(self, versions_chatted_to, expected_disp
             message = ChatMessage.objects.create(
                 message_type=ChatMessageType.AI, content="", chat=experiment_session.chat
             )
-            message.add_system_tag(tag=f"v{version}", tag_category=TagCategories.EXPERIMENT_VERSION)
+            message.create_and_add_tag(tag=f"v{version}", tag_category=TagCategories.EXPERIMENT_VERSION)
 
         assert experiment_session.experiment_version_for_display == expected_display_val
 
diff --git a/apps/pipelines/models.py b/apps/pipelines/models.py
index 9291402a1a..b54746b1f6 100644
--- a/apps/pipelines/models.py
+++ b/apps/pipelines/models.py
@@ -12,7 +12,6 @@
 from django.urls import reverse
 from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
 
-from apps.annotations.models import TagCategories
 from apps.chat.models import ChatMessage, ChatMessageType
 from apps.custom_actions.form_utils import set_custom_actions
 from apps.custom_actions.mixins import CustomActionOperationMixin
@@ -360,15 +359,20 @@ def invoke(
             return ChatMessage(content=output)
 
     def _save_message_to_history(
-        self, session: ExperimentSession, message: str, type_: ChatMessageType, metadata: dict, tags: list[str] = None
+        self,
+        session: ExperimentSession,
+        message: str,
+        type_: ChatMessageType,
+        metadata: dict,
+        tags: list[tuple] = None,
     ) -> ChatMessage:
         chat_message = ChatMessage.objects.create(
             chat=session.chat, message_type=type_.value, content=message, metadata=metadata
         )
 
         if tags:
-            for tag in tags:
-                chat_message.add_system_tag(tag, TagCategories.BOT_RESPONSE)
+            for tag_value, category in tags:
+                chat_message.create_and_add_tag(tag_value, category or "")
         return chat_message
 
     @transaction.atomic()
diff --git a/apps/pipelines/nodes/base.py b/apps/pipelines/nodes/base.py
index 2ac768f68c..ef16f6b959 100644
--- a/apps/pipelines/nodes/base.py
+++ b/apps/pipelines/nodes/base.py
@@ -295,6 +295,11 @@ def process(
         state = self._prepare_state(self.node_id, incoming_edges, state)
         output = self._process(input=state["node_input"], state=state)
         output["path"] = [(state["node_source"], self.node_id, outgoing_edges)]
+        get_output_tags_fn = getattr(self, "get_output_tags", None)
+        if callable(get_output_tags_fn):
+            output["output_message_tags"] = get_output_tags_fn()
+        else:
+            output["output_message_tags"] = []
         return output
 
     def _process(self, input: str, state: PipelineState) -> PipelineState:
diff --git a/apps/pipelines/nodes/nodes.py b/apps/pipelines/nodes/nodes.py
index 8960172584..36818a7edc 100644
--- a/apps/pipelines/nodes/nodes.py
+++ b/apps/pipelines/nodes/nodes.py
@@ -4,6 +4,7 @@
 import logging
 import random
 import time
+import unicodedata
 from typing import Annotated, Literal, Self
 
 import tiktoken
@@ -58,7 +59,26 @@
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if isinstance(x, str) and len(x) == 0 else x)]
 
 
-class RenderTemplate(PipelineNode):
+class OutputMessageTagMixin(BaseModel):
+    tag: str = Field(
+        default="",
+        title="Message Tag",
+        description="The tag that the output message should be tagged with",
+    )
+
+    @field_validator("tag", mode="after")
+    @classmethod
+    def normalize_tag(cls, value: str) -> str:
+        return unicodedata.normalize("NFC", value)
+
+    def get_output_tags(self) -> list[tuple[str, None]]:
+        tags: list[tuple[str, None]] = []
+        if self.tag:
+            tags.append((self.tag, None))
+        return tags
+
+
+class RenderTemplate(PipelineNode, OutputMessageTagMixin):
     """Renders a Jinja template"""
 
     model_config = ConfigDict(
@@ -242,7 +262,7 @@ def _process(self, input, **kwargs) -> PipelineState:
         return PipelineState.from_node_output(node_name=self.name, node_id=self.node_id, output=output.content)
 
 
-class LLMResponseWithPrompt(LLMResponse, HistoryMixin):
+class LLMResponseWithPrompt(LLMResponse, HistoryMixin, OutputMessageTagMixin):
     """Uses and LLM to respond to the input."""
 
     model_config = ConfigDict(
@@ -383,7 +403,7 @@ def _process(self, input, state: PipelineState) -> PipelineState:
         )
 
 
-class SendEmail(PipelineNode):
+class SendEmail(PipelineNode, OutputMessageTagMixin):
     """Send the input to the node to the list of addresses provided"""
 
     model_config = ConfigDict(
@@ -758,7 +778,9 @@ def validate_data_schema(cls, value):
         return value
 
 
-class ExtractStructuredData(ExtractStructuredDataNodeMixin, LLMResponse, StructuredDataSchemaValidatorMixin):
+class ExtractStructuredData(
+    ExtractStructuredDataNodeMixin, LLMResponse, StructuredDataSchemaValidatorMixin, OutputMessageTagMixin
+):
     """Extract structured data from the input"""
 
     model_config = ConfigDict(
@@ -780,7 +802,9 @@ def is_passthrough(self) -> bool:
         return False
 
 
-class ExtractParticipantData(ExtractStructuredDataNodeMixin, LLMResponse, StructuredDataSchemaValidatorMixin):
+class ExtractParticipantData(
+    ExtractStructuredDataNodeMixin, LLMResponse, StructuredDataSchemaValidatorMixin, OutputMessageTagMixin
+):
     """Extract structured data and saves it as participant data"""
 
     model_config = ConfigDict(
@@ -853,7 +877,7 @@ def post_extraction_hook(self, output, state):
             )
 
 
-class AssistantNode(PipelineNode):
+class AssistantNode(PipelineNode, OutputMessageTagMixin):
     """Calls an OpenAI assistant"""
 
     model_config = ConfigDict(
@@ -942,7 +966,7 @@ def main(input: str, **kwargs) -> str:
 """
 
 
-class CodeNode(PipelineNode):
+class CodeNode(PipelineNode, OutputMessageTagMixin):
     """Runs python"""
 
     model_config = ConfigDict(
diff --git a/apps/pipelines/tests/node_schemas/AssistantNode.json b/apps/pipelines/tests/node_schemas/AssistantNode.json
index 2d5f27efae..5f588ceb47 100644
--- a/apps/pipelines/tests/node_schemas/AssistantNode.json
+++ b/apps/pipelines/tests/node_schemas/AssistantNode.json
@@ -1,6 +1,12 @@
 {
   "description": "Calls an OpenAI assistant",
   "properties": {
+    "tag": {
+      "default": "",
+      "description": "The tag that the output message should be tagged with",
+      "title": "Message Tag",
+      "type": "string"
+    },
     "name": {
       "title": "Node Name",
       "type": "string",
diff --git a/apps/pipelines/tests/node_schemas/CodeNode.json b/apps/pipelines/tests/node_schemas/CodeNode.json
index 40998860cd..4398187dce 100644
--- a/apps/pipelines/tests/node_schemas/CodeNode.json
+++ b/apps/pipelines/tests/node_schemas/CodeNode.json
@@ -1,6 +1,12 @@
 {
   "description": "Runs python",
   "properties": {
+    "tag": {
+      "default": "",
+      "description": "The tag that the output message should be tagged with",
+      "title": "Message Tag",
+      "type": "string"
+    },
     "name": {
       "title": "Node Name",
       "type": "string",
diff --git a/apps/pipelines/tests/node_schemas/ExtractParticipantData.json b/apps/pipelines/tests/node_schemas/ExtractParticipantData.json
index 150f5e46db..414c22425d 100644
--- a/apps/pipelines/tests/node_schemas/ExtractParticipantData.json
+++ b/apps/pipelines/tests/node_schemas/ExtractParticipantData.json
@@ -1,6 +1,12 @@
 {
   "description": "Extract structured data and saves it as participant data",
   "properties": {
+    "tag": {
+      "default": "",
+      "description": "The tag that the output message should be tagged with",
+      "title": "Message Tag",
+      "type": "string"
+    },
     "llm_provider_id": {
       "title": "LLM Model",
       "type": "integer",
diff --git a/apps/pipelines/tests/node_schemas/ExtractStructuredData.json b/apps/pipelines/tests/node_schemas/ExtractStructuredData.json
index 9dcd2c31db..e14f7dfcf4 100644
--- a/apps/pipelines/tests/node_schemas/ExtractStructuredData.json
+++ b/apps/pipelines/tests/node_schemas/ExtractStructuredData.json
@@ -1,6 +1,12 @@
 {
   "description": "Extract structured data from the input",
   "properties": {
+    "tag": {
+      "default": "",
+      "description": "The tag that the output message should be tagged with",
+      "title": "Message Tag",
+      "type": "string"
+    },
     "llm_provider_id": {
       "title": "LLM Model",
       "type": "integer",
diff --git a/apps/pipelines/tests/node_schemas/LLMResponseWithPrompt.json b/apps/pipelines/tests/node_schemas/LLMResponseWithPrompt.json
index c482ebeabf..554e4e497d 100644
--- a/apps/pipelines/tests/node_schemas/LLMResponseWithPrompt.json
+++ b/apps/pipelines/tests/node_schemas/LLMResponseWithPrompt.json
@@ -1,6 +1,12 @@
 {
   "description": "Uses and LLM to respond to the input.",
   "properties": {
+    "tag": {
+      "default": "",
+      "description": "The tag that the output message should be tagged with",
+      "title": "Message Tag",
+      "type": "string"
+    },
     "llm_provider_id": {
       "title": "LLM Model",
       "type": "integer",
diff --git a/apps/pipelines/tests/node_schemas/RenderTemplate.json b/apps/pipelines/tests/node_schemas/RenderTemplate.json
index 65f3b7a525..887a76bb53 100644
--- a/apps/pipelines/tests/node_schemas/RenderTemplate.json
+++ b/apps/pipelines/tests/node_schemas/RenderTemplate.json
@@ -1,6 +1,12 @@
 {
   "description": "Renders a Jinja template",
   "properties": {
+    "tag": {
+      "default": "",
+      "description": "The tag that the output message should be tagged with",
+      "title": "Message Tag",
+      "type": "string"
+    },
     "name": {
       "title": "Node Name",
       "type": "string",
diff --git a/apps/pipelines/tests/node_schemas/SendEmail.json b/apps/pipelines/tests/node_schemas/SendEmail.json
index 8eff077375..64dc19246c 100644
--- a/apps/pipelines/tests/node_schemas/SendEmail.json
+++ b/apps/pipelines/tests/node_schemas/SendEmail.json
@@ -1,6 +1,12 @@
 {
   "description": "Send the input to the node to the list of addresses provided",
   "properties": {
+    "tag": {
+      "default": "",
+      "description": "The tag that the output message should be tagged with",
+      "title": "Message Tag",
+      "type": "string"
+    },
     "name": {
       "title": "Node Name",
       "type": "string",
diff --git a/apps/pipelines/tests/test_pipeline_runs.py b/apps/pipelines/tests/test_pipeline_runs.py
index 271ccf7d21..46963064c7 100644
--- a/apps/pipelines/tests/test_pipeline_runs.py
+++ b/apps/pipelines/tests/test_pipeline_runs.py
@@ -53,14 +53,13 @@ def test_save_trace_metadata(pipeline: Pipeline, session: ExperimentSession):
 
 @pytest.mark.django_db()
 def test_save_metadata_and_tagging(pipeline: Pipeline, session: ExperimentSession):
-    output_message_tags = ["test_tag_1"]
+    output_message_tags = [("test_tag_1", TagCategories.BOT_RESPONSE)]
     pipeline_state = PipelineState(messages=["Hi"], output_message_tags=output_message_tags)
 
-    with mock.patch.object(ChatMessage, "add_system_tag") as mock_add_system_tag:
+    with mock.patch.object(ChatMessage, "create_and_add_tag") as mock_add_system_tag:
         pipeline.invoke(pipeline_state, session, session.experiment, TracingService.empty())
-        for tag in output_message_tags:
-            mock_add_system_tag.assert_any_call(tag, TagCategories.BOT_RESPONSE)
+        for tag_value, category in output_message_tags:
+            mock_add_system_tag.assert_any_call(tag_value, category or "")
 
-        assert (
-            mock_add_system_tag.call_count == len(output_message_tags) + 1
-        )  # add version tag also calls add system tag
+        # add version tag also calls add system tag
+        assert mock_add_system_tag.call_count == len(output_message_tags) + 1


Reviews:
user: login: SmittieC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T06:27:09Z
user: login: SmittieC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T06:28:23Z
user: login: SmittieC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T06:30:16Z
user: login: SmittieC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T06:41:03Z
user: login: barora05
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T07:11:02Z
user: login: snopoke
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T07:24:33Z
user: login: snopoke
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T07:25:29Z
user: login: snopoke
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:03:43Z
user: login: barora05
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:16:06Z
user: login: barora05
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:19:28Z
user: login: snopoke
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T12:05:51Z
user: login: barora05
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T12:31:41Z
user: login: SmittieC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T12:57:12Z
user: login: SmittieC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:00:57Z
user: login: SmittieC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:05:21Z
user: login: SmittieC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:06:01Z
user: login: SmittieC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:07:22Z
user: login: SmittieC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:12:05Z
user: login: barora05
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:23:39Z
user: login: barora05
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:25:35Z
user: login: barora05
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:25:37Z
user: login: snopoke
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:26:20Z
user: login: SmittieC
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T09:46:43Z
user: login: snopoke
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T10:35:00Z
user: login: snopoke
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T15:01:43Z

Review comments:
diff_hunk: @@ -380,6 +384,7 @@ def _process(self, input, state: PipelineState) -> PipelineState:
             node_id=self.node_id,
             output=result.output,
             output_message_metadata=history_manager.output_message_metadata,
+            tag=f"{self.name}:{self.tag}" if self.tag and self.tag.strip() else None,
path: apps/pipelines/nodes/nodes.py
user: login: SmittieC
body: Rather use the existing `output_message_tags` key. See [here](https://github.com/dimagi/open-chat-studio/blob/b3fa54ffe2b27c6c488a5982dd1fed65bd147bc4/apps/pipelines/nodes/base.py#L116) on how the router adds tags. I think we should use the same approach here.
created_at: 2025-05-21T06:27:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,7 +33,7 @@ class Tag(TagBase, BaseTeamModel):
     name = models.CharField(verbose_name=pgettext_lazy("A tag name", "name"), max_length=100)
     created_by = models.ForeignKey("users.CustomUser", on_delete=models.DO_NOTHING, null=True, default=None)
     is_system_tag = models.BooleanField(default=False)
-    category = models.CharField(choices=TagCategories.choices, blank=True, default="")
+    category = models.CharField(choices=TagCategories.choices, blank=True, default="", null=False)
path: apps/annotations/models.py
user: login: SmittieC
body: Remember to run `python manage.py makemigrations` as well
created_at: 2025-05-21T06:28:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -284,6 +284,10 @@ class LLMResponseWithPrompt(LLMResponse, HistoryMixin):
         description="Custom actions to enable for the bot",
         json_schema_extra=UiSchema(widget=Widgets.multiselect, options_source=OptionsSource.custom_actions),
     )
+    tag: str = Field(
+        default="Tag to add to output message",
path: apps/pipelines/nodes/nodes.py
user: login: SmittieC
body: How about this?

```suggestion
        default="The tag that the output message should be tagged with",
```
created_at: 2025-05-21T06:30:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -380,6 +384,7 @@ def _process(self, input, state: PipelineState) -> PipelineState:
             node_id=self.node_id,
             output=result.output,
             output_message_metadata=history_manager.output_message_metadata,
+            tag=f"{self.name}:{self.tag}" if self.tag and self.tag.strip() else None,
path: apps/pipelines/nodes/nodes.py
user: login: SmittieC
body: I see now we do want to use a separate Tag Category for this, and not the `BOT_RESPONSE` one. Hmmm.. @snopoke is this correct?
created_at: 2025-05-21T06:41:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -380,6 +384,7 @@ def _process(self, input, state: PipelineState) -> PipelineState:
             node_id=self.node_id,
             output=result.output,
             output_message_metadata=history_manager.output_message_metadata,
+            tag=f"{self.name}:{self.tag}" if self.tag and self.tag.strip() else None,
path: apps/pipelines/nodes/nodes.py
user: login: barora05
body: I have used separate var as while adding system tags the tag category for output_message_tags is BOT_RESPONSE where as we want the tag category as blank for this case
created_at: 2025-05-21T07:11:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,7 +33,7 @@ class Tag(TagBase, BaseTeamModel):
     name = models.CharField(verbose_name=pgettext_lazy("A tag name", "name"), max_length=100)
     created_by = models.ForeignKey("users.CustomUser", on_delete=models.DO_NOTHING, null=True, default=None)
     is_system_tag = models.BooleanField(default=False)
-    category = models.CharField(choices=TagCategories.choices, blank=True, default="")
+    category = models.CharField(choices=TagCategories.choices, blank=True, default="", null=False)
path: apps/annotations/models.py
user: login: snopoke
body: `null=False` is the default value so this change doesn't actually do anything
created_at: 2025-05-21T07:21:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -380,6 +384,7 @@ def _process(self, input, state: PipelineState) -> PipelineState:
             node_id=self.node_id,
             output=result.output,
             output_message_metadata=history_manager.output_message_metadata,
+            tag=f"{self.name}:{self.tag}" if self.tag and self.tag.strip() else None,
path: apps/pipelines/nodes/nodes.py
user: login: snopoke
body: I had originally thought we'd reuse output_message_tags and store the tag category along with the value:

```
output_message_tags = [
  ("tag1", None), ("routernode:keyword", TagCategory.BOT_RESPONSE)
]
```
created_at: 2025-05-21T07:23:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -380,6 +384,7 @@ def _process(self, input, state: PipelineState) -> PipelineState:
             node_id=self.node_id,
             output=result.output,
             output_message_metadata=history_manager.output_message_metadata,
+            tag=f"{self.name}:{self.tag}" if self.tag and self.tag.strip() else None,
path: apps/pipelines/nodes/nodes.py
user: login: snopoke
body: I don't think these tags should have the node name appended to them. I think it should just be the plain value provided by the user.
created_at: 2025-05-21T07:24:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -284,6 +284,10 @@ class LLMResponseWithPrompt(LLMResponse, HistoryMixin):
         description="Custom actions to enable for the bot",
         json_schema_extra=UiSchema(widget=Widgets.multiselect, options_source=OptionsSource.custom_actions),
     )
+    tag: str = Field(
+        default="Tag to add to output message",
path: apps/pipelines/nodes/nodes.py
user: login: snopoke
body: I think this should be on all nodes, not just LLM nodes.
created_at: 2025-05-21T07:25:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,9 +56,23 @@
 from apps.utils.prompt import OcsPromptTemplate, PromptVars, validate_prompt_variables
 
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if x == "" else x)]
+DEFAULT_TAG = "The tag that the output message should be tagged with"
 
 
-class RenderTemplate(PipelineNode):
+class TagMixin(BaseModel):
path: apps/pipelines/nodes/nodes.py
user: login: snopoke
body: This could go on the `BasePipelineNode` class which would avoid having to add this mixin on each node.
created_at: 2025-05-21T11:00:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,9 +56,23 @@
 from apps.utils.prompt import OcsPromptTemplate, PromptVars, validate_prompt_variables
 
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if x == "" else x)]
+DEFAULT_TAG = "The tag that the output message should be tagged with"
 
 
-class RenderTemplate(PipelineNode):
+class TagMixin(BaseModel):
+    tag: str = Field(
+        default=DEFAULT_TAG,
+        json_schema_extra=UiSchema(widget=Widgets.expandable_text),
+    )
path: apps/pipelines/nodes/nodes.py
user: login: snopoke
body: suggested field changes:
```suggestion
    tag: str = Field(
        default="",
        title="Message Tag",
        description="The tag that the output message should be tagged with",
    )
```
created_at: 2025-05-21T11:03:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,9 +56,23 @@
 from apps.utils.prompt import OcsPromptTemplate, PromptVars, validate_prompt_variables
 
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if x == "" else x)]
+DEFAULT_TAG = "The tag that the output message should be tagged with"
 
 
-class RenderTemplate(PipelineNode):
+class TagMixin(BaseModel):
path: apps/pipelines/nodes/nodes.py
user: login: barora05
body: didn't add it there as then it will also be included in start and end node.
created_at: 2025-05-21T11:16:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,9 +56,23 @@
 from apps.utils.prompt import OcsPromptTemplate, PromptVars, validate_prompt_variables
 
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if x == "" else x)]
+DEFAULT_TAG = "The tag that the output message should be tagged with"
 
 
-class RenderTemplate(PipelineNode):
+class TagMixin(BaseModel):
+    tag: str = Field(
+        default=DEFAULT_TAG,
+        json_schema_extra=UiSchema(widget=Widgets.expandable_text),
+    )
path: apps/pipelines/nodes/nodes.py
user: login: barora05
body: > tag: str = Field(

Tried this. description is not visible as help text inside or outside the field. Do we want it without help_text on UI?
created_at: 2025-05-21T11:19:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,9 +56,23 @@
 from apps.utils.prompt import OcsPromptTemplate, PromptVars, validate_prompt_variables
 
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if x == "" else x)]
+DEFAULT_TAG = "The tag that the output message should be tagged with"
 
 
-class RenderTemplate(PipelineNode):
+class TagMixin(BaseModel):
+    tag: str = Field(
+        default=DEFAULT_TAG,
+        json_schema_extra=UiSchema(widget=Widgets.expandable_text),
+    )
path: apps/pipelines/nodes/nodes.py
user: login: snopoke
body: You need to remove the tag field from the `LlmResponseWithPrompt` node since it's overriding the one in the mixin.
created_at: 2025-05-21T12:05:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,9 +56,23 @@
 from apps.utils.prompt import OcsPromptTemplate, PromptVars, validate_prompt_variables
 
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if x == "" else x)]
+DEFAULT_TAG = "The tag that the output message should be tagged with"
 
 
-class RenderTemplate(PipelineNode):
+class TagMixin(BaseModel):
+    tag: str = Field(
+        default=DEFAULT_TAG,
+        json_schema_extra=UiSchema(widget=Widgets.expandable_text),
+    )
path: apps/pipelines/nodes/nodes.py
user: login: barora05
body: got it.done
created_at: 2025-05-21T12:31:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,7 +33,7 @@ class Tag(TagBase, BaseTeamModel):
     name = models.CharField(verbose_name=pgettext_lazy("A tag name", "name"), max_length=100)
     created_by = models.ForeignKey("users.CustomUser", on_delete=models.DO_NOTHING, null=True, default=None)
     is_system_tag = models.BooleanField(default=False)
-    category = models.CharField(choices=TagCategories.choices, blank=True, default="")
+    category = models.CharField(choices=TagCategories.choices, blank=True, default="", null=False)
path: apps/annotations/models.py
user: login: SmittieC
body: Ah yes, right, thanks
created_at: 2025-05-21T12:57:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -341,15 +340,20 @@ def invoke(
             return ChatMessage(content=output)
 
     def _save_message_to_history(
-        self, session: ExperimentSession, message: str, type_: ChatMessageType, metadata: dict, tags: list[str] = None
+        self,
+        session: ExperimentSession,
+        message: str,
+        type_: ChatMessageType,
+        metadata: dict,
+        tags: list[str] = None,
path: apps/pipelines/models.py
user: login: SmittieC
body: ```suggestion
        tags: list[tuple] = None,
```
created_at: 2025-05-22T09:00:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -79,6 +79,7 @@ class PipelineState(dict):
     output_message_metadata: Annotated[dict, merge_dicts]
     attachments: list = Field(default=[])
     output_message_tags: Annotated[list[str], operator.add]
+    tag: str
path: apps/pipelines/nodes/base.py
user: login: SmittieC
body: This can be removed?
created_at: 2025-05-22T09:05:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -335,6 +341,9 @@ def get_output_map(self) -> dict[str, str]:
     def get_output_tags(self, selected_route) -> list[str]:
         raise NotImplementedError()
 
+    def get_output_tag(self) -> str:
path: apps/pipelines/nodes/base.py
user: login: SmittieC
body: This isn't used?
created_at: 2025-05-22T09:06:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,9 +56,23 @@
 from apps.utils.prompt import OcsPromptTemplate, PromptVars, validate_prompt_variables
 
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if x == "" else x)]
+DEFAULT_TAG = "The tag that the output message should be tagged with"
 
 
-class RenderTemplate(PipelineNode):
+class TagMixin(BaseModel):
path: apps/pipelines/nodes/nodes.py
user: login: SmittieC
body: Nit (don't update if you don't want to): I'm thinking that the name `OutputTagMixin` or `OutputMessageTagMixin` might be a slightly more indicative name?
created_at: 2025-05-22T09:07:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -295,6 +296,11 @@ def process(
         state = self._prepare_state(self.node_id, incoming_edges, state)
         output = self._process(input=state["node_input"], state=state)
         output["path"] = [(state["node_source"], self.node_id, outgoing_edges)]
+        get_output_tags_fn = getattr(self, "get_output_tags", None)
+        if callable(get_output_tags_fn):
+            output["output_message_tags"] = get_output_tags_fn()
path: apps/pipelines/nodes/base.py
user: login: SmittieC
body: Why not just do this?
```python
output["output_message_tags"] = self.get_output_tags()
```
created_at: 2025-05-22T09:12:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -341,15 +340,20 @@ def invoke(
             return ChatMessage(content=output)
 
     def _save_message_to_history(
-        self, session: ExperimentSession, message: str, type_: ChatMessageType, metadata: dict, tags: list[str] = None
+        self,
+        session: ExperimentSession,
+        message: str,
+        type_: ChatMessageType,
+        metadata: dict,
+        tags: list[str] = None,
     ) -> ChatMessage:
         chat_message = ChatMessage.objects.create(
             chat=session.chat, message_type=type_.value, content=message, metadata=metadata
         )
 
         if tags:
-            for tag in tags:
-                chat_message.add_system_tag(tag, TagCategories.BOT_RESPONSE)
+            for tag_value, category in tags:
+                chat_message.add_system_tag(tag_value, category or "")
path: apps/pipelines/models.py
user: login: snopoke
body: I think we should rename this method to `create_and_add_tag` (`add_tag` is already taken) and only if the category is not empty we can mark it as a system tag.

created_at: 2025-05-22T09:17:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -295,6 +296,11 @@ def process(
         state = self._prepare_state(self.node_id, incoming_edges, state)
         output = self._process(input=state["node_input"], state=state)
         output["path"] = [(state["node_source"], self.node_id, outgoing_edges)]
+        get_output_tags_fn = getattr(self, "get_output_tags", None)
+        if callable(get_output_tags_fn):
+            output["output_message_tags"] = get_output_tags_fn()
path: apps/pipelines/nodes/base.py
user: login: barora05
body: This is also used in start and end node. This will give error that attribute get_output_tags does not exist
created_at: 2025-05-22T09:23:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,9 +56,24 @@
 from apps.utils.prompt import OcsPromptTemplate, PromptVars, validate_prompt_variables
 
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if x == "" else x)]
+DEFAULT_TAG = "The tag that the output message should be tagged with"
 
 
-class RenderTemplate(PipelineNode):
+class TagMixin(BaseModel):
+    tag: str = Field(
+        default="",
+        title="Message Tag",
+        description="The tag that the output message should be tagged with",
+    )
+
+    def get_output_tags(self) -> list[tuple[str, str | None]]:
+        tags: list[tuple[str, str | None]] = []
+        if self.tag and self.tag != DEFAULT_TAG:
path: apps/pipelines/nodes/nodes.py
user: login: snopoke
body: ```suggestion
        if self.tag:
```
created_at: 2025-05-22T09:25:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,9 +56,24 @@
 from apps.utils.prompt import OcsPromptTemplate, PromptVars, validate_prompt_variables
 
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if x == "" else x)]
+DEFAULT_TAG = "The tag that the output message should be tagged with"
path: apps/pipelines/nodes/nodes.py
user: login: snopoke
body: ```suggestion
```
created_at: 2025-05-22T09:25:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -335,6 +341,9 @@ def get_output_map(self) -> dict[str, str]:
     def get_output_tags(self, selected_route) -> list[str]:
         raise NotImplementedError()
 
+    def get_output_tag(self) -> str:
path: apps/pipelines/nodes/base.py
user: login: barora05
body: yes. removed. I am using get_output_tags only now.
created_at: 2025-05-22T09:25:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -79,6 +79,7 @@ class PipelineState(dict):
     output_message_metadata: Annotated[dict, merge_dicts]
     attachments: list = Field(default=[])
     output_message_tags: Annotated[list[str], operator.add]
+    tag: str
path: apps/pipelines/nodes/base.py
user: login: barora05
body: yes
created_at: 2025-05-22T09:25:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -56,9 +56,24 @@
 from apps.utils.prompt import OcsPromptTemplate, PromptVars, validate_prompt_variables
 
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if x == "" else x)]
+DEFAULT_TAG = "The tag that the output message should be tagged with"
 
 
-class RenderTemplate(PipelineNode):
+class TagMixin(BaseModel):
+    tag: str = Field(
+        default="",
+        title="Message Tag",
+        description="The tag that the output message should be tagged with",
+    )
+
+    def get_output_tags(self) -> list[tuple[str, str | None]]:
+        tags: list[tuple[str, str | None]] = []
path: apps/pipelines/nodes/nodes.py
user: login: snopoke
body: ```suggestion
    def get_output_tags(self) -> list[tuple[str, None]]:
        tags: list[tuple[str, None]] = []
```
created_at: 2025-05-22T09:25:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -295,6 +296,11 @@ def process(
         state = self._prepare_state(self.node_id, incoming_edges, state)
         output = self._process(input=state["node_input"], state=state)
         output["path"] = [(state["node_source"], self.node_id, outgoing_edges)]
+        get_output_tags_fn = getattr(self, "get_output_tags", None)
+        if callable(get_output_tags_fn):
+            output["output_message_tags"] = get_output_tags_fn()
path: apps/pipelines/nodes/base.py
user: login: SmittieC
body: I see. I'm starting to wonder if we shouldn't make a subclass for user editable and non-editable (start + end) nodes. Not for this PR of course.
created_at: 2025-05-22T09:46:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,7 +58,21 @@
 OptionalInt = Annotated[int | None, BeforeValidator(lambda x: None if isinstance(x, str) and len(x) == 0 else x)]
 
 
-class RenderTemplate(PipelineNode):
+class OutputMessageTagMixin(BaseModel):
+    tag: str = Field(
path: apps/pipelines/nodes/nodes.py
user: login: snopoke
body: this value should receive the same unicode normalization treatement as in the tag views:

`unicodedata.normalize("NFC", value)`

You can do this with an `after` validator (https://docs.pydantic.dev/dev/concepts/validators/#field-validators)
created_at: 2025-05-22T10:35:00Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov-commenter
created_at: 2025-05-21T05:50:57Z
body: ## [Codecov](https://app.codecov.io/gh/dimagi/open-chat-studio/pull/1612?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dimagi) Report
Attention: Patch coverage is `94.44444%` with `2 lines` in your changes missing coverage. Please review.

:white_check_mark: All tests successful. No failed tests found.

| [Files with missing lines](https://app.codecov.io/gh/dimagi/open-chat-studio/pull/1612?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dimagi) | Patch % | Lines |
|---|---|---|
| [apps/pipelines/nodes/nodes.py](https://app.codecov.io/gh/dimagi/open-chat-studio/pull/1612?src=pr&el=tree&filepath=apps%2Fpipelines%2Fnodes%2Fnodes.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dimagi#diff-YXBwcy9waXBlbGluZXMvbm9kZXMvbm9kZXMucHk=) | 89.47% | [2 Missing :warning: ](https://app.codecov.io/gh/dimagi/open-chat-studio/pull/1612?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dimagi) |


:loudspeaker: Thoughts on this report? [Let us know!](https://about.codecov.io/pull-request-comment-report/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=dimagi)

<details><summary> :rocket: New features to boost your workflow: </summary>

- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: sentry[bot]
created_at: 2025-05-22T19:32:58Z
body: ## Suspect Issues
This pull request was deployed and Sentry observed the following issues:

- ‼️ **ValueError: too many values to unpack (expected 2)** `apps.experiments.tasks.get_response_for_webchat...` [View Issue](https://dimagi.sentry.io/issues/6624535451/?referrer=github-pr-bot)

<sub>Did you find this useful? React with a 👍 or 👎</sub>
author_association: CONTRIBUTOR