Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions backend/api_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/backend/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Any

from rest_framework.serializers import ModelSerializer
from utils.serializer import ModelSerializer
Comment thread
greptile-apps[bot] marked this conversation as resolved.

from backend.constants import RequestKey

Expand Down
3 changes: 2 additions & 1 deletion backend/notification_v2/serializers.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
15 changes: 9 additions & 6 deletions backend/prompt_studio/prompt_studio_core_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": {
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ class PromptStudioOutputSerializer(AuditSerializer):
class Meta:
model = PromptStudioOutputManager
fields = "__all__"
# Stored LLM response (`output`) and document chunks (`context`)
# routinely contain <thinking>, <context>, 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)
Expand Down
9 changes: 9 additions & 0 deletions backend/prompt_studio/prompt_studio_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ class ToolStudioPromptSerializer(AuditSerializer):
class Meta:
model = ToolStudioPrompt
fields = "__all__"
# LLM prompt text legitimately contains XML/HTML-like markup
# (e.g. <context>, <thinking>). `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):
Expand Down
3 changes: 2 additions & 1 deletion backend/tags/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
7 changes: 6 additions & 1 deletion backend/utils/serializer/sanitization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
11 changes: 11 additions & 0 deletions backend/utils/tests/test_sanitized_serializer_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<h1>x</h1>"})
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": "<thinking>step 1</thinking>"}
Expand Down
7 changes: 1 addition & 6 deletions backend/workflow_manager/workflow_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@
font-weight: bold;
font-size: 14px;
}

.editable-text-error-message {
display: block;
margin-top: 2px;
font-size: 12px;
}
48 changes: 29 additions & 19 deletions frontend/src/components/custom-tools/editable-text/EditableText.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,6 +17,7 @@ function EditableText({
isTextarea,
placeHolder,
isCoverageLoading,
error,
}) {
const name = isTextarea ? "prompt" : "prompt_key";
const [triggerHandleChange, setTriggerHandleChange] = useState(false);
Expand Down Expand Up @@ -94,24 +95,32 @@ function EditableText({
}

return (
<Input
className="width-100 input-header-text"
value={text}
onChange={handleTextChange}
placeholder="Enter Key"
name={name}
size="small"
style={{ backgroundColor: "transparent" }}
variant={`${!isEditing && !isHovered ? "borderless" : "outlined"}`}
autoSize={true}
onMouseOver={() => setIsHovered(true)}
onMouseOut={() => setIsHovered(false)}
onBlur={handleBlur}
onClick={() => setIsEditing(true)}
disabled={
isCoverageLoading || isSinglePassExtractLoading || isPublicSource
}
/>
<>
<Input
className="width-100 input-header-text"
value={text}
onChange={handleTextChange}
placeholder="Enter Key"
name={name}
size="small"
style={{ backgroundColor: "transparent" }}
variant={`${!isEditing && !isHovered ? "borderless" : "outlined"}`}
autoSize={true}
onMouseOver={() => setIsHovered(true)}
onMouseOut={() => setIsHovered(false)}
onBlur={handleBlur}
onClick={() => setIsEditing(true)}
disabled={
isCoverageLoading || isSinglePassExtractLoading || isPublicSource
}
status={error ? "error" : undefined}
/>
{error && (
<Typography.Text type="danger" className="editable-text-error-message">
{error}
</Typography.Text>
)}
</>
);
}

Expand All @@ -126,6 +135,7 @@ EditableText.propTypes = {
isTextarea: PropTypes.bool,
placeHolder: PropTypes.string,
isCoverageLoading: PropTypes.bool.isRequired,
error: PropTypes.string,
};

export { EditableText };
3 changes: 3 additions & 0 deletions frontend/src/components/custom-tools/prompt-card/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function Header({
handleSpsLoading,
enforceType,
isAgenticTableReady = true,
promptKeyError,
}) {
const {
selectedDoc,
Expand Down Expand Up @@ -335,6 +336,7 @@ function Header({
handleChange={handleChange}
placeHolder={updatePlaceHolder}
isCoverageLoading={isCoverageLoading}
error={promptKeyError}
/>
</Col>
<Col span={12} className="display-flex-right">
Expand Down Expand Up @@ -493,6 +495,7 @@ Header.propTypes = {
handleSpsLoading: PropTypes.func.isRequired,
enforceType: PropTypes.string,
isAgenticTableReady: PropTypes.bool,
promptKeyError: PropTypes.string,
};

export { Header };
31 changes: 29 additions & 2 deletions frontend/src/components/custom-tools/prompt-card/PromptCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -381,6 +407,7 @@ const PromptCard = memo(
coverageCountData={coverageCountData}
isChallenge={isChallenge}
handleSelectHighlight={handleSelectHighlight}
fieldErrors={fieldErrors}
/>
<OutputForDocModal
open={openOutputForDoc}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function PromptCardItems({
coverageCountData,
isChallenge,
handleSelectHighlight,
fieldErrors,
}) {
const {
llmProfiles,
Expand Down Expand Up @@ -231,6 +232,7 @@ function PromptCardItems({
handleSpsLoading={handleSpsLoading}
enforceType={enforceType}
isAgenticTableReady={isAgenticTableReady}
promptKeyError={fieldErrors?.prompt_key}
/>
</Space>
</div>
Expand Down Expand Up @@ -369,6 +371,7 @@ function PromptCardItems({
}

PromptCardItems.propTypes = {
fieldErrors: PropTypes.object,
promptDetails: PropTypes.object.isRequired,
enforceTypeList: PropTypes.array,
allTableSettings: PropTypes.array,
Expand Down