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 @@ -29,7 +29,7 @@
serialize_group_refs,
serialize_owner_refs,
)
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 @@ -84,11 +84,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.
Comment thread
chandrasekharan-zipstack 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,5 +1,6 @@
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
Expand All @@ -13,7 +14,7 @@ class NotificationSettingsSerializer(serializers.Serializer):
club_interval_seconds = serializers.IntegerField(min_value=60, max_value=7200)


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
14 changes: 8 additions & 6 deletions backend/prompt_studio/prompt_studio_core_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
serialize_owner_refs,
)
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 @@ -106,6 +106,13 @@ class Meta:
extra_kwargs = {
"shared_to_org": {"read_only": True},
}
# LLM-facing text legitimately contains XML-like markup.
html_safe_fields = (
"summarize_prompt",
"preamble",
"postamble",
"output",
)

unique_error_message_map: dict[str, dict[str, str]] = {
"unique_tool_name": {
Expand All @@ -119,11 +126,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,8 @@ class PromptStudioOutputSerializer(AuditSerializer):
class Meta:
model = PromptStudioOutputManager
fields = "__all__"
# LLM output/context legitimately contains XML-like tags.
html_safe_fields = ("output", "context")

def to_representation(self, instance):
data = super().to_representation(instance)
Expand Down
7 changes: 7 additions & 0 deletions backend/prompt_studio/prompt_studio_v2/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ class Meta:
# View owns uniqueness (IntegrityError->DuplicateData on create); drop
# the DRF auto-validator that 400s on re-save / PUT before the view runs.
validators = []
# Prompts and LLM output legitimately contain XML-like markup.
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": "<script>x</script>"})
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 @@ -19,7 +19,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 @@ -64,11 +64,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 @@ -37,6 +37,19 @@ try {
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

const getFieldErrors = (err) => {
const data = err?.response?.data;
if (data?.type !== "validation_error" || !Array.isArray(data?.errors)) {
return {};
}
return data.errors.reduce((acc, e) => {
if (e?.attr) {
acc[e.attr] = e.detail || "Invalid value";
}
return acc;
}, {});
};

function DocumentParser({
addPromptInstance,
scrollToBottom,
Expand Down Expand Up @@ -140,7 +153,12 @@ function DocumentParser({
return `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/prompt/${urlPath}`;
};

const handleChangePromptCard = async (name, value, promptId) => {
const handleChangePromptCard = async (
name,
value,
promptId,
onFieldError,
) => {
const promptsAndNotes = details?.prompts || [];

if (name === "prompt_key") {
Expand Down Expand Up @@ -205,7 +223,16 @@ function DocumentParser({
return res;
})
.catch((err) => {
setAlertDetails(handleException(err, "Failed to update"));
// Inline rendering of field errors is opt-in: a caller that has no
// renderer for the offending field returns false and gets the alert,
// so a rejected value can never fail silently.
const fieldErrors = getFieldErrors(err);
const handledInline =
Object.keys(fieldErrors).length > 0 && onFieldError?.(fieldErrors);
if (!handledInline) {
setAlertDetails(handleException(err, "Failed to update"));
}
throw err;
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@
font-weight: bold;
font-size: 13px;
}

.editable-text-error-message {
display: block;
margin-top: 2px;
font-size: 12px;
}
54 changes: 35 additions & 19 deletions frontend/src/components/custom-tools/editable-text/EditableText.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import debounce from "lodash/debounce";
import PropTypes from "prop-types";
import { useCallback, useEffect, useRef, useState } from "react";
import { Input } from "@/components/ui/shims/antd-inputs";
import { Typography } from "@/components/ui/shims/antd-typography";

import "./EditableText.css";
import { useCustomToolStore } from "../../../store/custom-tool-store";
Expand All @@ -17,6 +18,7 @@ function EditableText({
isTextarea,
placeHolder,
isCoverageLoading,
error,
}) {
const name = isTextarea ? "prompt" : "prompt_key";
const [triggerHandleChange, setTriggerHandleChange] = useState(false);
Expand All @@ -25,6 +27,11 @@ function EditableText({
const { isSinglePassExtractLoading, isPublicSource } = useCustomToolStore();

useEffect(() => {
// A rejected edit rolls the stored value back; keep the typed text on
// screen so the user can correct it in place.
if (error) {
return;
}
setText(defaultText);
}, [defaultText]);

Expand Down Expand Up @@ -59,7 +66,7 @@ function EditableText({
if (!triggerHandleChange) {
return;
}
handleChange(text, promptId, name, true, false);
handleChange(text, promptId, name, true, false).catch(() => {});
setTriggerHandleChange(false);
}, [triggerHandleChange]);

Expand Down Expand Up @@ -94,24 +101,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 +141,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 @@ -97,6 +97,7 @@ function Header({
handleSpsLoading,
enforceType,
isAgenticTableReady = true,
promptKeyError,
}) {
const {
selectedDoc,
Expand Down Expand Up @@ -384,6 +385,7 @@ function Header({
handleChange={handleChange}
placeHolder={updatePlaceHolder}
isCoverageLoading={isCoverageLoading}
error={promptKeyError}
/>
</Col>
<Col span={12} className="display-flex-right">
Expand Down Expand Up @@ -563,6 +565,7 @@ Header.propTypes = {
handleSpsLoading: PropTypes.func.isRequired,
enforceType: PropTypes.string,
isAgenticTableReady: PropTypes.bool,
promptKeyError: PropTypes.string,
};

export { Header };
Loading
Loading