diff --git a/backend/api_v2/serializers.py b/backend/api_v2/serializers.py index 7c9a5a7696..f7992deecb 100644 --- a/backend/api_v2/serializers.py +++ b/backend/api_v2/serializers.py @@ -22,7 +22,7 @@ ValidationError, ) from tags.serializers import TagParamsSerializer -from utils.input_sanitizer import validate_name_field, validate_no_html_tags +from utils.input_sanitizer import validate_name_field from utils.serializer.integrity_error_mixin import IntegrityErrorMixin from workflow_manager.endpoint_v2.models import WorkflowEndpoint from workflow_manager.workflow_v2.exceptions import ExecutionDoesNotExistError @@ -66,11 +66,6 @@ def validate_api_name(self, value: str) -> str: def validate_display_name(self, value: str) -> str: return validate_name_field(value, field_name="Display name") - def validate_description(self, value: str) -> str: - if value is None: - return value - return validate_no_html_tags(value, field_name="Description") - def validate_workflow(self, workflow): """Validate that the workflow has properly configured source and destination endpoints.""" # Get all endpoints for this workflow with related data diff --git a/backend/backend/serializers.py b/backend/backend/serializers.py index 62a92fc8a3..1bb0ccb2a9 100644 --- a/backend/backend/serializers.py +++ b/backend/backend/serializers.py @@ -1,6 +1,6 @@ from typing import Any -from rest_framework.serializers import ModelSerializer +from utils.serializer import ModelSerializer from backend.constants import RequestKey diff --git a/backend/notification_v2/serializers.py b/backend/notification_v2/serializers.py index 115487c481..cfbd174bb4 100644 --- a/backend/notification_v2/serializers.py +++ b/backend/notification_v2/serializers.py @@ -1,11 +1,12 @@ from rest_framework import serializers from utils.input_sanitizer import validate_name_field +from utils.serializer import ModelSerializer from .enums import AuthorizationType, NotificationType, PlatformType from .models import Notification -class NotificationSerializer(serializers.ModelSerializer): +class NotificationSerializer(ModelSerializer): notification_type = serializers.ChoiceField(choices=NotificationType.choices()) authorization_type = serializers.ChoiceField(choices=AuthorizationType.choices()) platform = serializers.ChoiceField(choices=PlatformType.choices(), required=False) diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py index 4f10ee2aa1..4cc2ddce1e 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py @@ -8,7 +8,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError from utils.FileValidator import FileValidator -from utils.input_sanitizer import validate_name_field, validate_no_html_tags +from utils.input_sanitizer import validate_name_field from utils.serializer.integrity_error_mixin import IntegrityErrorMixin from backend.serializers import AuditSerializer @@ -86,6 +86,14 @@ class CustomToolSerializer(IntegrityErrorMixin, AuditSerializer): class Meta: model = CustomTool fields = "__all__" + # Tool-level LLM context fields and stored LLM output; + # may legitimately contain XML/HTML-like markup. + html_safe_fields = ( + "summarize_prompt", + "preamble", + "postamble", + "output", + ) unique_error_message_map: dict[str, dict[str, str]] = { "unique_tool_name": { @@ -99,11 +107,6 @@ class Meta: def validate_tool_name(self, value: str) -> str: return validate_name_field(value, field_name="Tool name") - def validate_description(self, value: str) -> str: - if value is None: - return value - return validate_no_html_tags(value, field_name="Description") - def validate_summarize_llm_adapter(self, value): """Validate that the adapter type is LLM and is accessible to the user.""" if value is None: diff --git a/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py b/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py index 1c56e2323d..fa424b5e70 100644 --- a/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_output_manager_v2/serializers.py @@ -16,6 +16,13 @@ class PromptStudioOutputSerializer(AuditSerializer): class Meta: model = PromptStudioOutputManager fields = "__all__" + # Stored LLM response (`output`) and document chunks (`context`) + # routinely contain , , and other XML-like + # tags from the model. The serializer is mounted on a + # ModelViewSet with no class-level method restrictions, so writes + # routed through it (DRF admin / browsable API / any future write + # endpoint) must accept arbitrary text. + html_safe_fields = ("output", "context") def to_representation(self, instance): data = super().to_representation(instance) diff --git a/backend/prompt_studio/prompt_studio_v2/serializers.py b/backend/prompt_studio/prompt_studio_v2/serializers.py index 6a4d28032d..9bf3a30c70 100644 --- a/backend/prompt_studio/prompt_studio_v2/serializers.py +++ b/backend/prompt_studio/prompt_studio_v2/serializers.py @@ -26,6 +26,15 @@ class ToolStudioPromptSerializer(AuditSerializer): class Meta: model = ToolStudioPrompt fields = "__all__" + # LLM prompt text legitimately contains XML/HTML-like markup + # (e.g. , ). `output` is the LLM response, + # which may include any text the model produced. + html_safe_fields = ( + "prompt", + "assert_prompt", + "assertion_failure_prompt", + "output", + ) class ToolStudioIndexSerializer(serializers.Serializer): diff --git a/backend/tags/serializers.py b/backend/tags/serializers.py index a0b63dbc68..6dc6cfac98 100644 --- a/backend/tags/serializers.py +++ b/backend/tags/serializers.py @@ -3,11 +3,12 @@ from rest_framework import serializers from rest_framework.serializers import CharField, ValidationError +from utils.serializer import ModelSerializer from tags.models import Tag -class TagSerializer(serializers.ModelSerializer): +class TagSerializer(ModelSerializer): class Meta: model = Tag fields = ["id", "name", "description"] diff --git a/backend/utils/serializer/sanitization.py b/backend/utils/serializer/sanitization.py index 3c53ed73d4..70853abd9a 100644 --- a/backend/utils/serializer/sanitization.py +++ b/backend/utils/serializer/sanitization.py @@ -36,9 +36,14 @@ def __init__(self, *args, **kwargs): if name in exempt or field.read_only: continue if isinstance(field, drf.CharField): + # Prefer a user-visible label so errors read like + # "Prompt key must not contain..." instead of "prompt_key must...". + display_name = field.label or name.replace("_", " ").capitalize() # partial binds the field name at iteration time, avoiding the # late-binding closure trap of a bare lambda. - field.validators.append(partial(validate_no_html_tags, field_name=name)) + field.validators.append( + partial(validate_no_html_tags, field_name=display_name) + ) class ModelSerializer(SanitizedSerializerMixin, drf.ModelSerializer): diff --git a/backend/utils/tests/test_sanitized_serializer_mixin.py b/backend/utils/tests/test_sanitized_serializer_mixin.py index 20f4833a2a..a4ffe14d98 100644 --- a/backend/utils/tests/test_sanitized_serializer_mixin.py +++ b/backend/utils/tests/test_sanitized_serializer_mixin.py @@ -56,6 +56,17 @@ def test_each_field_gets_its_own_validator(self): msg = str(s.errors["description"][0]) assert "description" in msg.lower() + def test_error_message_uses_humanised_field_name(self): + """snake_case field names are surfaced as 'Snake case' in user-visible errors.""" + + class SnakeCaseSerializer(Serializer): + prompt_key = drf.CharField() + + s = SnakeCaseSerializer(data={"prompt_key": "

x

"}) + assert not s.is_valid() + msg = str(s.errors["prompt_key"][0]) + assert msg.startswith("Prompt key ") + def test_html_safe_fields_opts_out(self): s = WithOptOutSerializer( data={"name": "ok", "prompt": "step 1"} diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py index ed34592958..ab4051340f 100644 --- a/backend/workflow_manager/workflow_v2/serializers.py +++ b/backend/workflow_manager/workflow_v2/serializers.py @@ -14,7 +14,7 @@ ) from tool_instance_v2.serializers import ToolInstanceSerializer from tool_instance_v2.tool_instance_helper import ToolInstanceHelper -from utils.input_sanitizer import validate_name_field, validate_no_html_tags +from utils.input_sanitizer import validate_name_field from utils.serializer.integrity_error_mixin import IntegrityErrorMixin from backend.constants import RequestKey @@ -50,11 +50,6 @@ class Meta: def validate_workflow_name(self, value: str) -> str: return validate_name_field(value, field_name="Workflow name") - def validate_description(self, value: str) -> str: - if value is None: - return value - return validate_no_html_tags(value, field_name="Description") - def to_representation(self, instance: Workflow) -> dict[str, str]: representation: dict[str, str] = super().to_representation(instance) representation[WorkflowKey.WF_NAME] = instance.workflow_name diff --git a/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx b/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx index 63b1a037c5..d65a4d2b33 100644 --- a/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx +++ b/frontend/src/components/custom-tools/document-parser/DocumentParser.jsx @@ -205,6 +205,16 @@ function DocumentParser({ return res; }) .catch((err) => { + // Field-keyed DRF validation errors are surfaced inline on the + // prompt card; re-throw so the card's catch can render them. + const data = err?.response?.data; + const hasFieldError = + data?.type === "validation_error" && + Array.isArray(data?.errors) && + data.errors.some((e) => e?.attr); + if (hasFieldError) { + throw err; + } setAlertDetails(handleException(err, "Failed to update")); }); }; diff --git a/frontend/src/components/custom-tools/editable-text/EditableText.css b/frontend/src/components/custom-tools/editable-text/EditableText.css index fa526d14b3..7850b0e860 100644 --- a/frontend/src/components/custom-tools/editable-text/EditableText.css +++ b/frontend/src/components/custom-tools/editable-text/EditableText.css @@ -20,3 +20,9 @@ font-weight: bold; font-size: 14px; } + +.editable-text-error-message { + display: block; + margin-top: 2px; + font-size: 12px; +} diff --git a/frontend/src/components/custom-tools/editable-text/EditableText.jsx b/frontend/src/components/custom-tools/editable-text/EditableText.jsx index c2bc5bcebd..7890d881b7 100644 --- a/frontend/src/components/custom-tools/editable-text/EditableText.jsx +++ b/frontend/src/components/custom-tools/editable-text/EditableText.jsx @@ -1,4 +1,4 @@ -import { Input } from "antd"; +import { Input, Typography } from "antd"; import debounce from "lodash/debounce"; import PropTypes from "prop-types"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -17,6 +17,7 @@ function EditableText({ isTextarea, placeHolder, isCoverageLoading, + error, }) { const name = isTextarea ? "prompt" : "prompt_key"; const [triggerHandleChange, setTriggerHandleChange] = useState(false); @@ -94,24 +95,32 @@ function EditableText({ } return ( - setIsHovered(true)} - onMouseOut={() => setIsHovered(false)} - onBlur={handleBlur} - onClick={() => setIsEditing(true)} - disabled={ - isCoverageLoading || isSinglePassExtractLoading || isPublicSource - } - /> + <> + setIsHovered(true)} + onMouseOut={() => setIsHovered(false)} + onBlur={handleBlur} + onClick={() => setIsEditing(true)} + disabled={ + isCoverageLoading || isSinglePassExtractLoading || isPublicSource + } + status={error ? "error" : undefined} + /> + {error && ( + + {error} + + )} + ); } @@ -126,6 +135,7 @@ EditableText.propTypes = { isTextarea: PropTypes.bool, placeHolder: PropTypes.string, isCoverageLoading: PropTypes.bool.isRequired, + error: PropTypes.string, }; export { EditableText }; diff --git a/frontend/src/components/custom-tools/prompt-card/Header.jsx b/frontend/src/components/custom-tools/prompt-card/Header.jsx index 30fe75fe54..16342f4af7 100644 --- a/frontend/src/components/custom-tools/prompt-card/Header.jsx +++ b/frontend/src/components/custom-tools/prompt-card/Header.jsx @@ -92,6 +92,7 @@ function Header({ handleSpsLoading, enforceType, isAgenticTableReady = true, + promptKeyError, }) { const { selectedDoc, @@ -335,6 +336,7 @@ function Header({ handleChange={handleChange} placeHolder={updatePlaceHolder} isCoverageLoading={isCoverageLoading} + error={promptKeyError} /> @@ -493,6 +495,7 @@ Header.propTypes = { handleSpsLoading: PropTypes.func.isRequired, enforceType: PropTypes.string, isAgenticTableReady: PropTypes.bool, + promptKeyError: PropTypes.string, }; export { Header }; diff --git a/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx b/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx index 29fda260e2..5e67360e52 100644 --- a/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx +++ b/frontend/src/components/custom-tools/prompt-card/PromptCard.jsx @@ -53,6 +53,7 @@ const PromptCard = memo( const [promptKey, setPromptKey] = useState(""); const [promptText, setPromptText] = useState(""); const [selectedLlmProfileId, setSelectedLlmProfileId] = useState(null); + const [fieldErrors, setFieldErrors] = useState({}); const [isCoverageLoading, setIsCoverageLoading] = useState(false); const [openOutputForDoc, setOpenOutputForDoc] = useState(false); @@ -157,6 +158,14 @@ const PromptCard = memo( const updatedPromptDetailsState = { ...promptDetailsState }; updatedPromptDetailsState[name] = value; + // New attempt — drop any prior inline error for this field. + setFieldErrors((prev) => { + if (!(name in prev)) return prev; + const next = { ...prev }; + delete next[name]; + return next; + }); + handleUpdateStatus( isUpdateStatus, promptId, @@ -178,9 +187,26 @@ const PromptCard = memo( setUpdateStatus, ); }) - .catch(() => { + .catch((err) => { handleUpdateStatus(isUpdateStatus, promptId, null, setUpdateStatus); - setPromptDetailsState(prevPromptDetailsState); + const data = err?.response?.data; + const fieldErrorMap = {}; + if ( + data?.type === "validation_error" && + Array.isArray(data?.errors) + ) { + data.errors.forEach((e) => { + if (e?.attr) { + fieldErrorMap[e.attr] = e.detail || "Invalid value"; + } + }); + } + if (Object.keys(fieldErrorMap).length > 0) { + // Keep the typed value so user can fix in place; show inline error. + setFieldErrors((prev) => ({ ...prev, ...fieldErrorMap })); + } else { + setPromptDetailsState(prevPromptDetailsState); + } }) .finally(() => { if (isUpdateStatus) { @@ -381,6 +407,7 @@ const PromptCard = memo( coverageCountData={coverageCountData} isChallenge={isChallenge} handleSelectHighlight={handleSelectHighlight} + fieldErrors={fieldErrors} /> @@ -369,6 +371,7 @@ function PromptCardItems({ } PromptCardItems.propTypes = { + fieldErrors: PropTypes.object, promptDetails: PropTypes.object.isRequired, enforceTypeList: PropTypes.array, allTableSettings: PropTypes.array,