feat(annotation): 替换模板配置表单为树形编辑器组件

- 移除 TemplateConfigurationForm 组件并引入 TemplateConfigurationTreeEditor
- 使用 useTagConfig Hook 获取标签配置
- 将自定义XML状态 customXml 替换为 labelConfig
- 删除模板编辑标签页和选择模板状态管理
- 更新XML解析逻辑支持更多对象和标注控件类型
- 添加配置验证功能确保至少包含数据对象和标注控件
- 在模板详情页面使用树形编辑器显示配置详情
- 更新任务创建页面集成新的树形配置编辑器
- 调整预览数据生成功能适配新的XML解析方式
This commit is contained in:
2026-01-23 16:11:59 +08:00
parent 76d06b9809
commit 3f566a0b08
14 changed files with 1383 additions and 900 deletions

View File

@@ -1,180 +1,108 @@
import type React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Card, Button, Input, Select, Divider, Form, message, Radio } from "antd"; import { Button, Input, Select, Form, message, Radio } from "antd";
import TextArea from "antd/es/input/TextArea"; import TextArea from "antd/es/input/TextArea";
import { import { DatabaseOutlined } from "@ant-design/icons";
DatabaseOutlined,
CheckOutlined,
PlusOutlined,
} from "@ant-design/icons";
import { mockTemplates } from "@/mock/annotation";
import CustomTemplateDialog from "./components/CustomTemplateDialog";
import TemplateConfigurationForm from "../components/TemplateConfigurationForm";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { queryDatasetsUsingGet } from "../../DataManagement/dataset.api"; import { queryDatasetsUsingGet } from "../../DataManagement/dataset.api";
import { mapDataset } from "@/pages/DataManagement/dataset.const";
import type { Dataset } from "@/pages/DataManagement/dataset.model";
import { import {
DatasetType, createAnnotationTaskUsingPost,
type Dataset, queryAnnotationTemplatesUsingGet,
} from "@/pages/DataManagement/dataset.model"; } from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model";
interface Template { import TemplateConfigurationTreeEditor from "../components/TemplateConfigurationTreeEditor";
id: string;
name: string;
category: string;
description: string;
type: "text" | "image";
preview?: string;
icon: React.ReactNode;
isCustom?: boolean;
}
const templateCategories = ["Computer Vision", "Natural Language Processing"];
export default function AnnotationTaskCreate() { export default function AnnotationTaskCreate() {
const navigate = useNavigate(); const navigate = useNavigate();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [showCustomTemplateDialog, setShowCustomTemplateDialog] =
useState(false);
const [selectedCategory, setSelectedCategory] = useState("Computer Vision");
const [configMode, setConfigMode] = useState<"template" | "custom">("template");
const [searchQuery, setSearchQuery] = useState("");
const [datasetFilter, setDatasetFilter] = useState("all");
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null
);
const [datasets, setDatasets] = useState<Dataset[]>([]); const [datasets, setDatasets] = useState<Dataset[]>([]);
const [selectedDataset, setSelectedDataset] = useState<Dataset | null>(null); const [templates, setTemplates] = useState<AnnotationTemplate[]>([]);
const [labelConfig, setLabelConfig] = useState("");
// 用于Form的受控数据 const [configMode, setConfigMode] = useState<"template" | "custom">("template");
const [formValues, setFormValues] = useState({ const [submitting, setSubmitting] = useState(false);
name: "",
description: "",
datasetId: "",
templateId: "",
});
const fetchDatasets = async () => { const fetchDatasets = async () => {
const { data } = await queryDatasetsUsingGet(); try {
setDatasets(data.results || []); const { data } = await queryDatasetsUsingGet({ page: 0, pageSize: 1000 });
const list = data?.content || [];
setDatasets(list.map((item: any) => mapDataset(item)) || []);
} catch (error) {
console.error("加载数据集失败:", error);
message.error("加载数据集失败");
}
};
const fetchTemplates = async () => {
try {
const response = await queryAnnotationTemplatesUsingGet({ page: 1, size: 200 });
if (response.code === 200 && response.data) {
setTemplates(response.data.content || []);
} else {
message.error(response.message || "加载模板失败");
}
} catch (error) {
console.error("加载模板失败:", error);
message.error("加载模板失败");
}
}; };
useEffect(() => { useEffect(() => {
fetchDatasets(); fetchDatasets();
fetchTemplates();
}, []); }, []);
const filteredTemplates = mockTemplates.filter( const handleTemplateSelect = (value?: string) => {
(template) => template.category === selectedCategory if (!value) {
); setLabelConfig("");
return;
const handleDatasetSelect = (datasetId: string) => {
const dataset = datasets.find((ds) => ds.id === datasetId) || null;
setSelectedDataset(dataset);
setFormValues((prev) => ({ ...prev, datasetId }));
if (dataset?.type === DatasetType.PRETRAIN_IMAGE) {
setSelectedCategory("Computer Vision");
} else if (dataset?.type === DatasetType.PRETRAIN_TEXT) {
setSelectedCategory("Natural Language Processing");
} }
setSelectedTemplate(null); const selectedTemplate = templates.find((template) => template.id === value);
setFormValues((prev) => ({ ...prev, templateId: "" })); setLabelConfig(selectedTemplate?.labelConfig || "");
}; };
const handleTemplateSelect = (template: Template) => { const handleConfigModeChange = (e: any) => {
setSelectedTemplate(template);
setFormValues((prev) => ({ ...prev, templateId: template.id }));
};
const handleValuesChange = (_, allValues) => {
setFormValues({ ...formValues, ...allValues });
};
const handleConfigModeChange = (e) => {
const mode = e.target.value; const mode = e.target.value;
setConfigMode(mode); setConfigMode(mode);
if (mode === "custom") { if (mode === "custom") {
// Initialize default values for custom configuration form.setFieldsValue({ templateId: undefined });
form.setFieldsValue({
objects: [{ name: "image", type: "Image", value: "$image" }],
labels: [],
});
// Clear template selection
setSelectedTemplate(null);
setFormValues((prev) => ({ ...prev, templateId: "" }));
} }
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
const dataset = datasets.find((ds) => ds.id === values.datasetId); if (!labelConfig.trim()) {
message.error("请配置标注模板");
let template;
if (configMode === "template") {
template = mockTemplates.find(
(tpl) => tpl.id === values.templateId
);
if (!template) {
message.error("请选择标注模板");
return;
}
} else {
// Custom configuration
const objects = values.objects;
const labels = values.labels;
if (!objects || objects.length === 0) {
message.error("请至少配置一个数据对象");
return;
}
if (!labels || labels.length === 0) {
message.error("请至少配置一个标签控件");
return; return;
} }
// Construct a temporary custom template object setSubmitting(true);
template = { await createAnnotationTaskUsingPost({
id: `custom-${Date.now()}`,
name: "自定义配置",
description: "任务特定的自定义配置",
type: selectedDataset?.type === DatasetType.PRETRAIN_TEXT ? "text" : "image",
isCustom: true,
configuration: {
objects,
labels
}
};
}
if (!dataset) {
message.error("请选择数据集");
return;
}
const taskData = {
name: values.name, name: values.name,
description: values.description, description: values.description,
dataset, datasetId: values.datasetId,
template, templateId: configMode === "template" ? values.templateId : undefined,
}; labelConfig: labelConfig.trim(),
// onCreateTask(taskData); // 实际创建逻辑 });
console.log("Submitting task data:", taskData);
message.success("标注任务创建成功"); message.success("标注任务创建成功");
navigate("/data/annotation"); navigate("/data/annotation");
} catch (e) { } catch (error: any) {
// 校验失败 if (error?.errorFields) {
console.error(e); message.error("请完善必填信息");
} else {
const msg = error?.message || error?.data?.message || "创建失败,请稍后重试";
message.error(msg);
console.error(error);
}
} finally {
setSubmitting(false);
} }
};
const handleSaveCustomTemplate = (templateData: any) => {
setSelectedTemplate(templateData);
setFormValues((prev) => ({ ...prev, templateId: templateData.id }));
message.success(`自定义模板 "${templateData.name}" 已创建`);
}; };
return ( return (
<div className="flex-overflow-auto"> <div className="flex-overflow-auto">
{/* Header */}
<div className="flex items-center mb-2"> <div className="flex items-center mb-2">
<Link to="/data/annotation"> <Link to="/data/annotation">
<Button type="text"> <Button type="text">
@@ -186,13 +114,7 @@ export default function AnnotationTaskCreate() {
<div className="flex-overflow-auto bg-white rounded-lg shadow-sm"> <div className="flex-overflow-auto bg-white rounded-lg shadow-sm">
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<Form <Form form={form} layout="vertical">
form={form}
initialValues={formValues}
onValuesChange={handleValuesChange}
layout="vertical"
>
{/* 基本信息 */}
<h2 className="font-medium text-gray-900 text-lg mb-2"></h2> <h2 className="font-medium text-gray-900 text-lg mb-2"></h2>
<Form.Item <Form.Item
label="任务名称" label="任务名称"
@@ -201,11 +123,7 @@ export default function AnnotationTaskCreate() {
> >
<Input placeholder="输入任务名称" /> <Input placeholder="输入任务名称" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item label="任务描述" name="description">
label="任务描述"
name="description"
rules={[{ required: true, message: "请输入任务描述" }]}
>
<TextArea placeholder="详细描述标注任务的要求和目标" rows={3} /> <TextArea placeholder="详细描述标注任务的要求和目标" rows={3} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -215,8 +133,6 @@ export default function AnnotationTaskCreate() {
> >
<Select <Select
optionFilterProp="children" optionFilterProp="children"
value={formValues.datasetId}
onChange={handleDatasetSelect}
placeholder="请选择数据集" placeholder="请选择数据集"
size="large" size="large"
options={datasets.map((dataset) => ({ options={datasets.map((dataset) => ({
@@ -236,10 +152,9 @@ export default function AnnotationTaskCreate() {
/> />
</Form.Item> </Form.Item>
{/* 模板配置 */}
<div className="flex items-center justify-between mt-6 mb-2"> <div className="flex items-center justify-between mt-6 mb-2">
<h2 className="font-medium text-gray-900 text-lg flex items-center gap-2"> <h2 className="font-medium text-gray-900 text-lg flex items-center gap-2">
</h2> </h2>
<Radio.Group value={configMode} onChange={handleConfigModeChange} buttonStyle="solid"> <Radio.Group value={configMode} onChange={handleConfigModeChange} buttonStyle="solid">
<Radio.Button value="template"></Radio.Button> <Radio.Button value="template"></Radio.Button>
@@ -247,163 +162,56 @@ export default function AnnotationTaskCreate() {
</Radio.Group> </Radio.Group>
</div> </div>
{configMode === "template" ? ( {configMode === "template" && (
<Form.Item <Form.Item
label="加载现有模板"
name="templateId" name="templateId"
rules={[{ required: true, message: "请选择标注模板" }]} rules={[{ required: true, message: "请选择标注模板" }]}
> >
<div className="flex"> <Select
{/* Category Sidebar */} placeholder="选择一个模板作为基础"
<div className="w-64 pr-6 border-r border-gray-200"> showSearch
<div className="space-y-2"> allowClear
{templateCategories.map((category) => { optionFilterProp="label"
const isAvailable = options={templates.map((template) => ({
selectedDataset?.type === "image" label: template.name,
? category === "Computer Vision" value: template.id,
: category === "Natural Language Processing"; title: template.description,
return ( config: template.labelConfig,
<Button }))}
key={category} onChange={handleTemplateSelect}
type={ optionRender={(option) => (
selectedCategory === category && isAvailable <div>
? "primary" <div style={{ fontWeight: 500 }}>{option.label}</div>
: "default" {option.data.title && (
} <div style={{ fontSize: 12, color: "#999", marginTop: 2 }}>
block {option.data.title}
disabled={!isAvailable}
onClick={() =>
isAvailable && setSelectedCategory(category)
}
style={{ textAlign: "left", marginBottom: 8 }}
>
{category}
</Button>
);
})}
<Button
type="dashed"
block
icon={<PlusOutlined />}
onClick={() => setShowCustomTemplateDialog(true)}
>
</Button>
</div> </div>
)}
</div> </div>
{/* Template Grid */} )}
<div className="flex-1 pl-6">
<div className="max-h-96 overflow-auto">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredTemplates.map((template) => (
<div
key={template.id}
className={`border rounded-lg cursor-pointer transition-all hover:shadow-md ${
formValues.templateId === template.id
? "border-blue-500 bg-blue-50"
: "border-gray-200"
}`}
onClick={() => handleTemplateSelect(template)}
>
{template.preview && (
<div className="aspect-video bg-gray-100 rounded-t-lg overflow-hidden">
<img
src={template.preview || "/placeholder.svg"}
alt={template.name}
className="w-full h-full object-cover"
/> />
</div>
)}
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
{template.icon}
<span className="font-medium text-sm">
{template.name}
</span>
</div>
</div>
<p className="text-xs text-gray-600">
{template.description}
</p>
</div>
</div>
))}
{/* Custom Template Option */}
<div
className={`border-2 border-dashed rounded-lg cursor-pointer transition-all hover:border-gray-400 ${
selectedTemplate?.isCustom
? "border-blue-500 bg-blue-50"
: "border-gray-300"
}`}
onClick={() => setShowCustomTemplateDialog(true)}
>
<div className="aspect-video bg-gray-50 rounded-t-lg flex items-center justify-center">
<PlusOutlined
style={{ fontSize: 32, color: "#bbb" }}
/>
</div>
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<PlusOutlined />
<span className="font-medium text-sm">
</span>
</div>
{selectedTemplate?.isCustom && (
<CheckOutlined style={{ color: "#1677ff" }} />
)}
</div>
<p className="text-xs text-gray-600">
</p>
</div>
</div>
</div>
</div>
</div>
</div>
{selectedTemplate && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<div className="flex items-center space-x-2">
<span
className="text-sm font-medium"
style={{ color: "#1677ff" }}
>
</span>
</div>
<p
className="text-sm"
style={{ color: "#1677ff", marginTop: 4 }}
>
{selectedTemplate.name} - {selectedTemplate.description}
</p>
</div>
)}
</Form.Item> </Form.Item>
) : (
<div className="border border-gray-200 rounded-lg p-6 bg-gray-50">
<TemplateConfigurationForm form={form} />
</div>
)} )}
<div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<TemplateConfigurationTreeEditor
value={labelConfig}
onChange={setLabelConfig}
height={420}
/>
</div>
</Form> </Form>
</div> </div>
<div className="flex gap-2 justify-end border-t border-gray-200 p-6"> <div className="flex gap-2 justify-end border-t border-gray-200 p-6">
<Button onClick={() => navigate("/data/annotation")}></Button> <Button onClick={() => navigate("/data/annotation")} disabled={submitting}>
<Button type="primary" onClick={handleSubmit}>
</Button>
<Button type="primary" onClick={handleSubmit} loading={submitting}>
</Button> </Button>
</div> </div>
</div> </div>
{/* Custom Template Dialog */}
<CustomTemplateDialog
open={showCustomTemplateDialog}
onOpenChange={setShowCustomTemplateDialog}
onSaveTemplate={handleSaveCustomTemplate}
datasetType={selectedDataset?.type || "image"}
/>
</div> </div>
); );
} }

View File

@@ -13,7 +13,8 @@ import {
import { type Dataset } from "@/pages/DataManagement/dataset.model"; import { type Dataset } from "@/pages/DataManagement/dataset.model";
import type { AnnotationTemplate, AnnotationTask } from "../../annotation.model"; import type { AnnotationTemplate, AnnotationTask } from "../../annotation.model";
import LabelStudioEmbed from "@/components/business/LabelStudioEmbed"; import LabelStudioEmbed from "@/components/business/LabelStudioEmbed";
import TemplateConfigurationForm from "../../components/TemplateConfigurationForm"; import TemplateConfigurationTreeEditor from "../../components/TemplateConfigurationTreeEditor";
import { useTagConfig } from "@/hooks/useTagConfig";
interface AnnotationTaskDialogProps { interface AnnotationTaskDialogProps {
open: boolean; open: boolean;
@@ -37,15 +38,10 @@ export default function CreateAnnotationTask({
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [nameManuallyEdited, setNameManuallyEdited] = useState(false); const [nameManuallyEdited, setNameManuallyEdited] = useState(false);
// Custom template state const [labelConfig, setLabelConfig] = useState("");
const [customXml, setCustomXml] = useState("");
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [previewTaskData, setPreviewTaskData] = useState<Record<string, any>>({}); const [previewTaskData, setPreviewTaskData] = useState<Record<string, any>>({});
const [configMode, setConfigMode] = useState<"template" | "custom">("template"); const [configMode, setConfigMode] = useState<"template" | "custom">("template");
// 模板编辑模式切换(可视化 vs XML)
const [templateEditTab, setTemplateEditTab] = useState<"visual" | "xml">("visual");
// 是否已选择模板(用于启用受限编辑模式)
const [hasSelectedTemplate, setHasSelectedTemplate] = useState(false);
// 数据集预览相关状态 // 数据集预览相关状态
const [datasetPreviewVisible, setDatasetPreviewVisible] = useState(false); const [datasetPreviewVisible, setDatasetPreviewVisible] = useState(false);
@@ -63,6 +59,7 @@ export default function CreateAnnotationTask({
// 任务详情加载状态(编辑模式) // 任务详情加载状态(编辑模式)
const [taskDetailLoading, setTaskDetailLoading] = useState(false); const [taskDetailLoading, setTaskDetailLoading] = useState(false);
const { config: tagConfig } = useTagConfig(false);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -101,7 +98,7 @@ export default function CreateAnnotationTask({
if (open) { if (open) {
manualForm.resetFields(); manualForm.resetFields();
setNameManuallyEdited(false); setNameManuallyEdited(false);
setCustomXml(""); setLabelConfig("");
setShowPreview(false); setShowPreview(false);
setPreviewTaskData({}); setPreviewTaskData({});
setDatasetPreviewData([]); setDatasetPreviewData([]);
@@ -122,23 +119,14 @@ export default function CreateAnnotationTask({
setSelectedDatasetId(taskDetail.datasetId); setSelectedDatasetId(taskDetail.datasetId);
// 获取实际的 labelConfig(优先使用任务自身的配置,回退到模板配置) // 获取实际的 labelConfig(优先使用任务自身的配置,回退到模板配置)
const labelConfig = taskDetail.labelConfig || taskDetail.template?.labelConfig; const configXml = taskDetail.labelConfig || taskDetail.template?.labelConfig;
// 设置 XML 配置用于预览 if (configXml) {
if (labelConfig) { setLabelConfig(configXml);
setCustomXml(labelConfig);
// 始终从 XML 解析配置,确保数据一致性
const parsed = parseXmlToConfig(labelConfig);
manualForm.setFieldsValue({
objects: parsed.objects,
labels: parsed.labels,
});
} }
// 编辑模式始终使用 custom 配置模式(不改变结构,只改标签 // 编辑模式始终使用 custom 配置模式(不改变结构,只改属性
setConfigMode("custom"); setConfigMode("custom");
// 编辑模式下启用受限编辑
setHasSelectedTemplate(true);
} }
}) })
.catch((err) => { .catch((err) => {
@@ -151,7 +139,6 @@ export default function CreateAnnotationTask({
} else { } else {
// 创建模式:重置为默认状态 // 创建模式:重置为默认状态
setConfigMode("template"); setConfigMode("template");
setHasSelectedTemplate(false);
setSelectedDatasetId(null); setSelectedDatasetId(null);
} }
} }
@@ -243,243 +230,212 @@ export default function CreateAnnotationTask({
} }
}; };
// 从 Label Studio XML 配置解析出 objects 和 labels const DEFAULT_OBJECT_TAGS = [
const parseXmlToConfig = (xml: string): { objects: any[], labels: any[] } => { "Image",
const objects: any[] = []; "Text",
const labels: any[] = []; "Audio",
"Video",
"HyperText",
"PDF",
"Markdown",
"Paragraphs",
"Table",
"AudioPlus",
"Timeseries",
"TimeSeries",
"Vector",
"Chat",
];
const DEFAULT_LABELING_CONTROL_TAGS = [
"Choices",
"Labels",
"RectangleLabels",
"PolygonLabels",
"EllipseLabels",
"KeyPointLabels",
"BrushLabels",
"TextArea",
"Number",
"DateTime",
"Rating",
"Taxonomy",
"ParagraphLabels",
"HyperTextLabels",
"Relations",
"Relation",
"Pairwise",
"TimeseriesLabels",
"TimeSeriesLabels",
"VectorLabels",
"VideoRectangle",
"MagicWand",
"BitmaskLabels",
];
const resolveObjectTags = () => {
const configTags = tagConfig?.objects ? Object.keys(tagConfig.objects) : [];
return new Set(configTags.length > 0 ? configTags : DEFAULT_OBJECT_TAGS);
};
const resolveLabelingControlTags = () => {
if (tagConfig?.controls) {
const labelingTags = Object.entries(tagConfig.controls)
.filter(([, config]) => config.category === "labeling")
.map(([tag]) => tag);
if (labelingTags.length > 0) {
return new Set(labelingTags);
}
}
return new Set(DEFAULT_LABELING_CONTROL_TAGS);
};
const parseXmlElements = (xml: string): Element[] => {
if (!xml) return [];
try { try {
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml"); const doc = parser.parseFromString(xml, "text/xml");
if (doc.getElementsByTagName("parsererror").length > 0) {
// 数据对象类型列表 return [];
const objectTypes = ["Image", "Text", "Audio", "Video", "HyperText", "Header", "Paragraphs", "TimeSeries", "TimeSeriesChannel"];
// 标签控件类型列表
const controlTypes = ["Choices", "Labels", "RectangleLabels", "PolygonLabels", "EllipseLabels", "KeyPointLabels", "BrushLabels", "TextArea", "Number", "DateTime", "Rating", "Taxonomy"];
// 解析数据对象
objectTypes.forEach(type => {
const elements = doc.getElementsByTagName(type);
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
const name = el.getAttribute("name") || "";
const value = el.getAttribute("value") || "";
if (name) {
objects.push({ name, type, value });
} }
return Array.from(doc.getElementsByTagName("*"));
} catch (error) {
console.error("解析 XML 失败", error);
return [];
} }
});
// 解析标签控件
controlTypes.forEach(type => {
const elements = doc.getElementsByTagName(type);
for (let i = 0; i < elements.length; i++) {
const el = elements[i];
const fromName = el.getAttribute("name") || "";
const toName = el.getAttribute("toName") || "";
const required = el.getAttribute("required") === "true";
if (fromName) {
const label: any = {
fromName,
toName,
type,
required,
}; };
// 解析选项/标签值 const extractObjectsFromLabelConfig = (xml: string) => {
if (type === "Choices") { const objectTags = resolveObjectTags();
const choices: string[] = []; const elements = parseXmlElements(xml);
const choiceElements = el.getElementsByTagName("Choice"); return elements
for (let j = 0; j < choiceElements.length; j++) { .filter((element) => objectTags.has(element.tagName))
const value = choiceElements[j].getAttribute("value"); .map((element) => ({
if (value) choices.push(value); name: element.getAttribute("name") || "",
} type: element.tagName,
label.options = choices; value: element.getAttribute("value") || "",
} else if (["Labels", "RectangleLabels", "PolygonLabels", "EllipseLabels", "KeyPointLabels", "BrushLabels"].includes(type)) { }))
const labelValues: string[] = []; .filter((item) => item.name || item.value);
const labelElements = el.getElementsByTagName("Label");
for (let j = 0; j < labelElements.length; j++) {
const value = labelElements[j].getAttribute("value");
if (value) labelValues.push(value);
}
label.labels = labelValues;
}
labels.push(label);
}
}
});
} catch (e) {
console.error("Failed to parse XML config:", e);
}
return { objects, labels };
}; };
const generateXmlFromConfig = (objects: any[], labels: any[]) => { const generatePreviewTaskDataFromLabelConfig = (xml: string) => {
let xml = '<View>\n'; const exampleDataByType: Record<string, any> = {
// Objects
if (objects) {
objects.forEach((obj: any) => {
xml += ` <${obj.type} name="${obj.name}" value="${obj.value}" />\n`;
});
}
// Controls
if (labels) {
labels.forEach((lbl: any) => {
let attrs = `name="${lbl.fromName}" toName="${lbl.toName}"`;
if (lbl.required) attrs += ' required="true"';
xml += ` <${lbl.type} ${attrs}>\n`;
const options = lbl.type === 'Choices' ? lbl.options : lbl.labels;
if (options && options.length) {
options.forEach((opt: string) => {
if (lbl.type === 'Choices') {
xml += ` <Choice value="${opt}" />\n`;
} else {
xml += ` <Label value="${opt}" />\n`;
}
});
}
xml += ` </${lbl.type}>\n`;
});
}
xml += '</View>';
return xml;
};
// 根据 objects 配置生成预览用的示例数据
const generateExampleData = (objects: any[]) => {
const exampleUrls: Record<string, string> = {
Image: "https://labelstud.io/images/opa-header.png", Image: "https://labelstud.io/images/opa-header.png",
Audio: "https://labelstud.io/files/sample.wav", Audio: "https://labelstud.io/files/sample.wav",
AudioPlus: "https://labelstud.io/files/sample.wav",
Video: "https://labelstud.io/files/sample.mp4", Video: "https://labelstud.io/files/sample.mp4",
};
const exampleTexts: Record<string, string> = {
Text: "这是示例文本,用于预览标注界面。", Text: "这是示例文本,用于预览标注界面。",
HyperText: "<p>这是示例 HTML 内容</p>", HyperText: "<p>这是示例 HTML 内容</p>",
Header: "示例标题", Markdown: "# 示例标题\n\n这里是示例 Markdown 内容。",
Paragraphs: "段落一\n\n段落二\n\n段落三", Paragraphs: "段落一\n\n段落二\n\n段落三",
PDF: "https://labelstud.io/files/sample.pdf",
Table: [
{ key: "字段A", value: "示例值A" },
{ key: "字段B", value: "示例值B" },
],
Chat: [
{ text: "你好,我想了解一下产品。", author: "user" },
{ text: "当然可以,请告诉我你的需求。", author: "assistant" },
],
Timeseries: "https://labelstud.io/files/sample.csv",
TimeSeries: "https://labelstud.io/files/sample.csv",
Vector: [0.12, 0.52, 0.33],
}; };
const objects = extractObjectsFromLabelConfig(xml);
if (objects.length === 0) {
return {
image: exampleDataByType.Image,
text: exampleDataByType.Text,
audio: exampleDataByType.Audio,
};
}
const data: Record<string, any> = {}; const data: Record<string, any> = {};
objects.forEach((obj) => {
const name = obj.name || "";
const value = obj.value || "";
const varName = value.startsWith("$") ? value.slice(1) : name || value;
if (!varName) return;
if (!objects || objects.length === 0) { if (exampleDataByType[obj.type]) {
// 默认数据 data[varName] = exampleDataByType[obj.type];
return { return;
image: exampleUrls.Image,
text: exampleTexts.Text,
audio: exampleUrls.Audio,
};
} }
objects.forEach((obj: any) => {
if (!obj?.name || !obj?.value) return;
// 变量名从 $varName 中提取
const varName = obj.value.startsWith("$") ? obj.value.slice(1) : obj.name;
if (exampleUrls[obj.type]) {
data[varName] = exampleUrls[obj.type];
} else if (exampleTexts[obj.type]) {
data[varName] = exampleTexts[obj.type];
} else {
// 未知类型,尝试根据名称猜测
const lowerName = varName.toLowerCase(); const lowerName = varName.toLowerCase();
if (lowerName.includes("image") || lowerName.includes("img")) { if (lowerName.includes("image") || lowerName.includes("img")) {
data[varName] = exampleUrls.Image; data[varName] = exampleDataByType.Image;
} else if (lowerName.includes("audio") || lowerName.includes("sound")) { } else if (lowerName.includes("audio") || lowerName.includes("sound")) {
data[varName] = exampleUrls.Audio; data[varName] = exampleDataByType.Audio;
} else if (lowerName.includes("video")) { } else if (lowerName.includes("video")) {
data[varName] = exampleUrls.Video; data[varName] = exampleDataByType.Video;
} else if (lowerName.includes("chat")) {
data[varName] = exampleDataByType.Chat;
} else { } else {
data[varName] = exampleTexts.Text; data[varName] = exampleDataByType.Text;
}
} }
}); });
return data; return data;
}; };
// 当选择模板时,加载模板配置到表单 // 当选择模板时,加载 XML 配置到树编辑器(仅快速填充)
const handleTemplateSelect = (value: string, option: any) => { const handleTemplateSelect = (value: string, option: any) => {
// 处理清除选择的情况
if (!value) { if (!value) {
setHasSelectedTemplate(false); setLabelConfig("");
setCustomXml("");
return; return;
} }
setHasSelectedTemplate(true); const selectedTemplate = templates.find((template) => template.id === value);
const configXml = selectedTemplate?.labelConfig || option?.config || "";
setLabelConfig(configXml);
};
if (option && option.config) { const validateLabelConfigForSubmit = () => {
setCustomXml(option.config); const xml = labelConfig.trim();
if (!xml) {
message.error("请配置标注模板");
return false;
} }
// 从模板列表中找到完整的模板数据 const elements = parseXmlElements(xml);
const selectedTemplate = templates.find(t => t.id === value); if (elements.length === 0) {
if (selectedTemplate?.configuration) { message.error("标注配置 XML 格式有误");
const { objects, labels } = selectedTemplate.configuration; return false;
manualForm.setFieldsValue({
objects: objects || [{ name: "image", type: "Image", value: "$image" }],
labels: labels || [],
});
} else if (option && option.config) {
// 如果没有结构化配置,设置默认值
manualForm.setFieldsValue({
objects: [{ name: "image", type: "Image", value: "$image" }],
labels: [],
});
} }
const objectTags = resolveObjectTags();
const labelingControlTags = resolveLabelingControlTags();
const objectCount = elements.filter((element) => objectTags.has(element.tagName)).length;
const labelingControlCount = elements.filter((element) => labelingControlTags.has(element.tagName)).length;
if (objectCount === 0) {
message.error("至少需要一个数据对象标签");
return false;
}
if (labelingControlCount === 0) {
message.error("至少需要一个标注控件标签");
return false;
}
return true;
}; };
const handleManualSubmit = async () => { const handleManualSubmit = async () => {
try { try {
const values = await manualForm.validateFields(); const values = await manualForm.validateFields();
if (!validateLabelConfigForSubmit()) {
let finalLabelConfig = "";
const objects = values.objects;
const labels = values.labels;
if (configMode === "template") {
// 模板模式:优先使用可视化配置生成 XML,回退到直接使用 XML 编辑器内容
if (templateEditTab === "visual" && objects && objects.length > 0) {
finalLabelConfig = generateXmlFromConfig(objects, labels || []);
} else if (customXml.trim()) {
finalLabelConfig = customXml;
} else {
message.error("请配置标注模板或选择一个现有模板");
return; return;
} }
} else {
// 自定义模式
if (!objects || objects.length === 0) {
message.error("请至少配置一个数据对象");
return;
}
if (!labels || labels.length === 0) {
message.error("请至少配置一个标签控件");
return;
}
finalLabelConfig = generateXmlFromConfig(objects, labels);
}
setSubmitting(true); setSubmitting(true);
const requestData = { const requestData = {
name: values.name, name: values.name,
description: values.description, description: values.description,
datasetId: values.datasetId, datasetId: values.datasetId,
templateId: configMode === 'template' ? values.templateId : undefined, templateId: configMode === "template" ? values.templateId : undefined,
labelConfig: finalLabelConfig, labelConfig: labelConfig.trim(),
// 编辑模式需要传递配置结构,用于后端保存
configuration: {
objects: objects || [],
labels: labels || [],
},
}; };
if (isEditMode && editTask) { if (isEditMode && editTask) {
@@ -505,17 +461,8 @@ export default function CreateAnnotationTask({
const handleConfigModeChange = (e: any) => { const handleConfigModeChange = (e: any) => {
const mode = e.target.value; const mode = e.target.value;
setConfigMode(mode); setConfigMode(mode);
// 两种模式都需要初始化默认值 if (mode === "custom") {
const currentObjects = manualForm.getFieldValue("objects"); manualForm.setFieldsValue({ templateId: undefined });
if (!currentObjects || currentObjects.length === 0) {
manualForm.setFieldsValue({
objects: [{ name: "image", type: "Image", value: "$image" }],
labels: [],
});
}
// 切换到模板模式时,重置 tab 到可视化
if (mode === "template") {
setTemplateEditTab("visual");
} }
}; };
@@ -647,19 +594,13 @@ export default function CreateAnnotationTask({
type="link" type="link"
size="small" size="small"
onClick={() => { onClick={() => {
const objects = manualForm.getFieldValue("objects"); if (!labelConfig.trim()) {
const labels = manualForm.getFieldValue("labels"); message.warning("请先配置标注模板");
return;
// 生成 XML
if (objects && objects.length > 0) {
const xml = generateXmlFromConfig(objects, labels || []);
setCustomXml(xml);
} }
// 生成适配的示例数据 const exampleData = generatePreviewTaskDataFromLabelConfig(labelConfig);
const exampleData = generateExampleData(objects);
setPreviewTaskData(exampleData); setPreviewTaskData(exampleData);
setShowPreview(true); setShowPreview(true);
}} }}
> >
@@ -672,15 +613,15 @@ export default function CreateAnnotationTask({
// 编辑模式:只允许修改标签取值 // 编辑模式:只允许修改标签取值
<div className="bg-gray-50 p-4 rounded-md border border-gray-200"> <div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<div className="text-sm text-gray-500 mb-3 bg-blue-50 p-2 rounded border border-blue-200"> <div className="text-sm text-gray-500 mb-3 bg-blue-50 p-2 rounded border border-blue-200">
/ /
</div> </div>
<div style={{ maxHeight: '350px', overflowY: 'auto' }}> <TemplateConfigurationTreeEditor
<TemplateConfigurationForm value={labelConfig}
form={manualForm} onChange={setLabelConfig}
restrictedMode={true} readOnlyStructure={true}
height={360}
/> />
</div> </div>
</div>
) : configMode === 'template' ? ( ) : configMode === 'template' ? (
<div className="bg-gray-50 p-4 rounded-md border border-gray-200"> <div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<Form.Item <Form.Item
@@ -688,6 +629,7 @@ export default function CreateAnnotationTask({
name="templateId" name="templateId"
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
help="选择模板后,配置将自动填充到可视化编辑器中,您可以继续修改。" help="选择模板后,配置将自动填充到可视化编辑器中,您可以继续修改。"
rules={[{ required: true, message: "请选择标注模板" }]}
> >
<Select <Select
placeholder="选择一个模板作为基础" placeholder="选择一个模板作为基础"
@@ -714,16 +656,19 @@ export default function CreateAnnotationTask({
/> />
</Form.Item> </Form.Item>
<div style={{ maxHeight: '350px', overflowY: 'auto' }}> <TemplateConfigurationTreeEditor
<TemplateConfigurationForm value={labelConfig}
form={manualForm} onChange={setLabelConfig}
restrictedMode={hasSelectedTemplate} height={360}
/> />
</div> </div>
</div>
) : ( ) : (
<div className="bg-gray-50 p-4 rounded-md border border-gray-200" style={{ maxHeight: '400px', overflowY: 'auto' }}> <div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<TemplateConfigurationForm form={manualForm} /> <TemplateConfigurationTreeEditor
value={labelConfig}
onChange={setLabelConfig}
height={360}
/>
</div> </div>
)} )}
@@ -745,7 +690,7 @@ export default function CreateAnnotationTask({
<div style={{ height: '600px', overflow: 'hidden' }}> <div style={{ height: '600px', overflow: 'hidden' }}>
{showPreview && ( {showPreview && (
<LabelStudioEmbed <LabelStudioEmbed
config={customXml} config={labelConfig}
task={{ task={{
id: 1, id: 1,
data: previewTaskData, data: previewTaskData,

View File

@@ -1,8 +1,7 @@
import React from "react"; import React from "react";
import { Modal, Descriptions, Tag, Space, Divider, Card, Typography } from "antd"; import { Modal, Descriptions, Tag, Divider, Card } from "antd";
import type { AnnotationTemplate } from "../annotation.model"; import type { AnnotationTemplate } from "../annotation.model";
import TemplateConfigurationTreeEditor from "../components/TemplateConfigurationTreeEditor";
const { Text, Paragraph } = Typography;
interface TemplateDetailProps { interface TemplateDetailProps {
visible: boolean; visible: boolean;
@@ -64,78 +63,17 @@ const TemplateDetail: React.FC<TemplateDetailProps> = ({
<Divider></Divider> <Divider></Divider>
<Card title="数据对象" size="small" style={{ marginBottom: 16 }}> <Card title="标注配置树" size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: "100%" }}> <TemplateConfigurationTreeEditor
{template.configuration.objects.map((obj, index) => ( value={template.labelConfig || ""}
<Card key={index} size="small" type="inner"> readOnly={true}
<Space> readOnlyStructure={true}
<Text strong></Text> height={360}
<Tag>{obj.name}</Tag> />
<Text strong></Text>
<Tag color="blue">{obj.type}</Tag>
<Text strong></Text>
<Tag color="green">{obj.value}</Tag>
</Space>
</Card>
))}
</Space>
</Card>
<Card title="标注控件" size="small" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: "100%" }} size="middle">
{template.configuration.labels.map((label, index) => (
<Card key={index} size="small" type="inner" title={`控件 ${index + 1}`}>
<Space direction="vertical" style={{ width: "100%" }}>
<div>
<Text strong></Text>
<Tag>{label.fromName}</Tag>
<Text strong style={{ marginLeft: 16 }}></Text>
<Tag>{label.toName}</Tag>
<Text strong style={{ marginLeft: 16 }}></Text>
<Tag color="purple">{label.type}</Tag>
{label.required && <Tag color="red"></Tag>}
</div>
{label.description && (
<div>
<Text strong></Text>
<Text type="secondary">{label.description}</Text>
</div>
)}
{label.options && label.options.length > 0 && (
<div>
<Text strong></Text>
<div style={{ marginTop: 4 }}>
{label.options.map((opt, i) => (
<Tag key={i} color="cyan">{opt}</Tag>
))}
</div>
</div>
)}
{label.labels && label.labels.length > 0 && (
<div>
<Text strong></Text>
<div style={{ marginTop: 4 }}>
{label.labels.map((lbl, i) => (
<Tag key={i} color="geekblue">{lbl}</Tag>
))}
</div>
</div>
)}
</Space>
</Card>
))}
</Space>
</Card> </Card>
{template.labelConfig && ( {template.labelConfig && (
<Card title="Label Studio XML 配置" size="small"> <Card title="Label Studio XML 配置" size="small">
<Paragraph>
<pre style={{ <pre style={{
background: "#f5f5f5", background: "#f5f5f5",
padding: 12, padding: 12,
@@ -145,7 +83,6 @@ const TemplateDetail: React.FC<TemplateDetailProps> = ({
}}> }}>
{template.labelConfig} {template.labelConfig}
</pre> </pre>
</Paragraph>
</Card> </Card>
)} )}
</Modal> </Modal>

View File

@@ -13,7 +13,7 @@ import {
} from "../annotation.api"; } from "../annotation.api";
import type { AnnotationTemplate } from "../annotation.model"; import type { AnnotationTemplate } from "../annotation.model";
import { DataTypeMap, ClassificationMap, AnnotationTypeMap } from "../annotation.const"; import { DataTypeMap, ClassificationMap, AnnotationTypeMap } from "../annotation.const";
import TemplateConfigurationForm from "../components/TemplateConfigurationForm"; import TemplateConfigurationTreeEditor from "../components/TemplateConfigurationTreeEditor";
const { TextArea } = Input; const { TextArea } = Input;
const { Option } = Select; const { Option } = Select;
@@ -35,6 +35,7 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
}) => { }) => {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [labelConfig, setLabelConfig] = useState("");
useEffect(() => { useEffect(() => {
if (visible && template && mode === "edit") { if (visible && template && mode === "edit") {
@@ -45,24 +46,26 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
labelingType: template.labelingType, labelingType: template.labelingType,
style: template.style, style: template.style,
category: template.category, category: template.category,
labels: template.configuration.labels,
objects: template.configuration.objects,
}); });
setLabelConfig(template.labelConfig || "");
} else if (visible && mode === "create") { } else if (visible && mode === "create") {
form.resetFields(); form.resetFields();
// Set default values // Set default values
form.setFieldsValue({ form.setFieldsValue({
style: "horizontal", style: "horizontal",
category: "custom", category: "custom",
labels: [],
objects: [{ name: "image", type: "Image", value: "$image" }],
}); });
setLabelConfig("");
} }
}, [visible, template, mode, form]); }, [visible, template, mode, form]);
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
if (!labelConfig.trim()) {
message.error("请配置标注模板");
return;
}
setLoading(true); setLoading(true);
console.log("Form values:", values); console.log("Form values:", values);
@@ -74,10 +77,7 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
labelingType: values.labelingType, labelingType: values.labelingType,
style: values.style, style: values.style,
category: values.category, category: values.category,
configuration: { labelConfig: labelConfig.trim(),
labels: values.labels,
objects: values.objects,
},
}; };
console.log("Request data:", requestData); console.log("Request data:", requestData);
@@ -190,7 +190,13 @@ const TemplateForm: React.FC<TemplateFormProps> = ({
</Form.Item> </Form.Item>
</Space> </Space>
<TemplateConfigurationForm form={form} /> <div className="bg-gray-50 p-4 rounded-md border border-gray-200">
<TemplateConfigurationTreeEditor
value={labelConfig}
onChange={setLabelConfig}
height={420}
/>
</div>
</Form> </Form>
</Modal> </Modal>
); );

View File

@@ -61,7 +61,7 @@ export interface AnnotationTemplate {
description?: string; description?: string;
dataType: string; dataType: string;
labelingType: string; labelingType: string;
configuration: TemplateConfiguration; configuration?: TemplateConfiguration;
labelConfig?: string; labelConfig?: string;
style: string; style: string;
category: string; category: string;

View File

@@ -6,7 +6,7 @@
export interface TagAttributeConfig { export interface TagAttributeConfig {
type?: "boolean" | "number" | "string"; type?: "boolean" | "number" | "string";
values?: string[]; values?: string[];
default?: any; default?: unknown;
description?: string; description?: string;
} }
@@ -65,7 +65,7 @@ export function parseTagConfig(
); );
const controlOptions: TagOption[] = Object.entries(config.controls) const controlOptions: TagOption[] = Object.entries(config.controls)
.filter(([_, value]) => { .filter(([, value]) => {
// If includeLabelingOnly is true, filter out layout controls // If includeLabelingOnly is true, filter out layout controls
if (includeLabelingOnly) { if (includeLabelingOnly) {
return value.category === "labeling"; return value.category === "labeling";
@@ -103,6 +103,7 @@ export function getControlDisplayName(controlType: string): string {
BrushLabels: "画笔分割", BrushLabels: "画笔分割",
EllipseLabels: "椭圆", EllipseLabels: "椭圆",
KeyPointLabels: "关键点", KeyPointLabels: "关键点",
HyperTextLabels: "HTML实体标注",
Rectangle: "矩形", Rectangle: "矩形",
Polygon: "多边形", Polygon: "多边形",
Ellipse: "椭圆", Ellipse: "椭圆",
@@ -151,7 +152,7 @@ export function getControlGroups(): Record<
return { return {
classification: { classification: {
label: "分类标注", label: "分类标注",
controls: ["Choices", "Taxonomy", "Labels", "Rating"], controls: ["Choices", "Taxonomy", "Labels", "Rating", "Ranker", "List"],
}, },
detection: { detection: {
label: "目标检测", label: "目标检测",
@@ -172,7 +173,7 @@ export function getControlGroups(): Record<
}, },
text: { text: {
label: "文本输入", label: "文本输入",
controls: ["TextArea", "Number", "DateTime"], controls: ["TextArea", "Number", "DateTime", "HyperTextLabels"],
}, },
other: { other: {
label: "其他", label: "其他",
@@ -181,6 +182,8 @@ export function getControlGroups(): Record<
"VectorLabels", "VectorLabels",
"ParagraphLabels", "ParagraphLabels",
"VideoRectangle", "VideoRectangle",
"Relations",
"Pairwise",
], ],
}, },
}; };

View File

@@ -0,0 +1,860 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
Button,
Card,
Input,
Select,
Space,
Tag,
Tree,
Typography,
} from "antd";
import type { DataNode, TreeProps } from "antd/es/tree";
import {
PlusOutlined,
DeleteOutlined,
BranchesOutlined,
} from "@ant-design/icons";
import { useTagConfig } from "@/hooks/useTagConfig";
import {
getControlDisplayName,
getObjectDisplayName,
type LabelStudioTagConfig,
} from "../annotation.tagconfig";
const { Text, Title } = Typography;
interface XmlNode {
id: string;
tag: string;
attrs: Record<string, string>;
children: XmlNode[];
text?: string;
}
interface ValidationIssue {
errors: string[];
warnings: string[];
}
interface TemplateConfigurationTreeEditorProps {
value?: string;
onChange?: (xml: string) => void;
readOnly?: boolean;
readOnlyStructure?: boolean;
height?: number | string;
}
const DEFAULT_ROOT_TAG = "View";
const CHILD_TAGS = ["Label", "Choice", "Relation", "Item", "Path", "Channel"];
const createId = () =>
`node_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const createEmptyTree = (): XmlNode => ({
id: createId(),
tag: DEFAULT_ROOT_TAG,
attrs: {},
children: [],
});
const escapeAttributeValue = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const escapeTextValue = (value: string) =>
value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const parseXmlToTree = (xml: string): { tree: XmlNode | null; error?: string } => {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
const parserError = doc.getElementsByTagName("parsererror");
if (parserError.length > 0) {
return { tree: null, error: "XML 格式错误,请检查标签闭合与层级。" };
}
const root = doc.documentElement;
if (!root || root.tagName !== DEFAULT_ROOT_TAG) {
return { tree: null, error: "根节点必须是 <View>。" };
}
const buildNode = (element: Element): XmlNode => {
const attrs: Record<string, string> = {};
Array.from(element.attributes).forEach((attr) => {
attrs[attr.name] = attr.value;
});
const elementChildren = Array.from(element.childNodes).filter(
(node) => node.nodeType === Node.ELEMENT_NODE
) as Element[];
const textNodes = Array.from(element.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
);
const children = elementChildren.map(buildNode);
const text =
children.length === 0
? textNodes.map((node) => node.textContent ?? "").join("").trim()
: undefined;
return {
id: createId(),
tag: element.tagName,
attrs,
children,
text: text || undefined,
};
};
return { tree: buildNode(root) };
} catch (error) {
console.error("解析 XML 失败", error);
return { tree: null, error: "解析 XML 失败,请检查配置内容。" };
}
};
const serializeTreeToXml = (node: XmlNode, indent = 0): string => {
const pad = " ".repeat(indent);
const attrs = Object.entries(node.attrs)
.filter(([key]) => key.trim())
.map(([key, value]) => ` ${key}="${escapeAttributeValue(value)}"`)
.join("");
if (node.children.length === 0 && !node.text) {
return `${pad}<${node.tag}${attrs} />`;
}
const lines: string[] = [`${pad}<${node.tag}${attrs}>`];
if (node.text) {
lines.push(`${pad} ${escapeTextValue(node.text)}`);
}
node.children.forEach((child) => {
lines.push(serializeTreeToXml(child, indent + 1));
});
lines.push(`${pad}</${node.tag}>`);
return lines.join("\n");
};
const updateNode = (
node: XmlNode,
targetId: string,
updater: (current: XmlNode) => XmlNode
): XmlNode => {
if (node.id === targetId) {
return updater(node);
}
let changed = false;
const children = node.children.map((child) => {
const updated = updateNode(child, targetId, updater);
if (updated !== child) changed = true;
return updated;
});
return changed ? { ...node, children } : node;
};
const removeNodeById = (
node: XmlNode,
targetId: string
): { node: XmlNode; removed?: XmlNode } => {
const index = node.children.findIndex((child) => child.id === targetId);
if (index >= 0) {
const removed = node.children[index];
const children = node.children.filter((child) => child.id !== targetId);
return { node: { ...node, children }, removed };
}
let removed: XmlNode | undefined;
const children = node.children.map((child) => {
const result = removeNodeById(child, targetId);
if (result.removed) removed = result.removed;
return result.node;
});
if (removed) {
return { node: { ...node, children }, removed };
}
return { node };
};
const findNodeWithParent = (
node: XmlNode,
targetId: string,
parent?: XmlNode
): { node?: XmlNode; parent?: XmlNode; index?: number } => {
if (node.id === targetId) {
return { node, parent, index: parent?.children.findIndex((c) => c.id === targetId) };
}
for (const child of node.children) {
const result = findNodeWithParent(child, targetId, node);
if (result.node) return result;
}
return {};
};
const isDescendant = (node: XmlNode, targetId: string): boolean => {
if (node.id === targetId) return true;
return node.children.some((child) => isDescendant(child, targetId));
};
const getNodeLabel = (node: XmlNode) => {
const name = node.attrs.name || node.attrs.value;
return name ? `${node.tag} (${name})` : node.tag;
};
const getDefaultName = (tag: string) => {
const lower = tag.toLowerCase();
if (lower.includes("text")) return "text";
if (lower.includes("image")) return "image";
if (lower.includes("audio")) return "audio";
if (lower.includes("video")) return "video";
if (lower.includes("pdf")) return "pdf";
if (lower.includes("chat")) return "chat";
if (lower.includes("table")) return "table";
if (lower.includes("timeseries") || lower.includes("time")) return "ts";
return lower;
};
const createNode = (
tag: string,
config: LabelStudioTagConfig | null,
objectNames: string[]
): XmlNode => {
const attrs: Record<string, string> = {};
const controlConfig = config?.controls?.[tag];
const objectConfig = config?.objects?.[tag];
const requiredAttrs = controlConfig?.required_attrs || objectConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
attrs[attr] = "";
});
if (objectConfig && attrs.name !== undefined) {
const name = getDefaultName(tag);
attrs.name = name;
if (attrs.value !== undefined) {
attrs.value = `$${name}`;
}
}
if (controlConfig && attrs.name !== undefined) {
attrs.name = getDefaultName(tag);
if (attrs.toName !== undefined) {
attrs.toName = objectNames[0] || "";
}
}
if (CHILD_TAGS.includes(tag)) {
attrs.value = attrs.value || "";
}
const node: XmlNode = {
id: createId(),
tag,
attrs,
children: [],
};
if (controlConfig?.requires_children && controlConfig.child_tag) {
node.children.push({
id: createId(),
tag: controlConfig.child_tag,
attrs: { value: "" },
children: [],
});
}
if (tag === "Style") {
node.text = "";
}
return node;
};
const collectObjectNames = (node: XmlNode, config: LabelStudioTagConfig | null): string[] => {
const names: string[] = [];
const objectTags = new Set(Object.keys(config?.objects || {}));
const traverse = (current: XmlNode) => {
if (objectTags.has(current.tag) && current.attrs.name) {
names.push(current.attrs.name);
}
current.children.forEach(traverse);
};
traverse(node);
return names;
};
const validateTree = (
node: XmlNode,
config: LabelStudioTagConfig | null
): Record<string, ValidationIssue> => {
const issues: Record<string, ValidationIssue> = {};
const objectTags = new Set(Object.keys(config?.objects || {}));
const controlTags = new Set(Object.keys(config?.controls || {}));
const objectNames = new Set(collectObjectNames(node, config));
const ensureIssue = (id: string) => {
if (!issues[id]) {
issues[id] = { errors: [], warnings: [] };
}
return issues[id];
};
let labelingControlCount = 0;
let objectCount = 0;
const validateNode = (current: XmlNode) => {
const issue = ensureIssue(current.id);
const controlConfig = config?.controls?.[current.tag];
const objectConfig = config?.objects?.[current.tag];
const isObject = objectTags.has(current.tag);
const isControl = controlTags.has(current.tag);
const isLabelingControl = isControl && controlConfig?.category === "labeling";
if (isObject) {
objectCount += 1;
const requiredAttrs = objectConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
if (!current.attrs[attr]) {
issue.errors.push(`对象缺少必填属性: ${attr}`);
}
});
if (current.attrs.value && !current.attrs.value.startsWith("$")) {
issue.errors.push("对象 value 应以 $ 开头");
}
}
if (isControl) {
if (isLabelingControl) {
labelingControlCount += 1;
const requiredAttrs = controlConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
if (!current.attrs[attr]) {
issue.errors.push(`控件缺少必填属性: ${attr}`);
}
});
if (current.attrs.toName) {
const toNames = current.attrs.toName
.split(",")
.map((name) => name.trim())
.filter(Boolean);
const invalid = toNames.filter((name) => !objectNames.has(name));
if (invalid.length > 0) {
issue.errors.push(`toName 未找到对象: ${invalid.join(", ")}`);
}
}
} else {
const requiredAttrs = controlConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
if (!current.attrs[attr]) {
issue.warnings.push(`布局标签缺少建议属性: ${attr}`);
}
});
}
if (controlConfig?.requires_children && controlConfig.child_tag) {
const children = current.children.filter(
(child) => child.tag === controlConfig.child_tag
);
if (children.length === 0) {
const message = `${current.tag} 需要至少一个 <${controlConfig.child_tag}> 子节点`;
if (isLabelingControl) {
issue.errors.push(message);
} else {
issue.warnings.push(message);
}
}
children.forEach((child) => {
if (!child.attrs.value) {
const message = `<${child.tag}> 缺少 value 属性`;
const childIssue = ensureIssue(child.id);
if (isLabelingControl) {
childIssue.errors.push(message);
} else {
childIssue.warnings.push(message);
}
}
});
}
}
current.children.forEach(validateNode);
};
validateNode(node);
if (node.tag === DEFAULT_ROOT_TAG) {
const rootIssue = ensureIssue(node.id);
if (objectCount === 0) {
rootIssue.errors.push("至少需要一个数据对象标签");
}
if (labelingControlCount === 0) {
rootIssue.errors.push("至少需要一个标注控件标签");
}
}
return issues;
};
const TemplateConfigurationTreeEditor = ({
value,
onChange,
readOnly = false,
readOnlyStructure = false,
height = 420,
}: TemplateConfigurationTreeEditorProps) => {
const { config } = useTagConfig(false);
const [tree, setTree] = useState<XmlNode>(() => createEmptyTree());
const [selectedId, setSelectedId] = useState<string>(tree.id);
const [parseError, setParseError] = useState<string | null>(null);
const lastSerialized = useRef<string>("");
const [addChildTag, setAddChildTag] = useState<string | undefined>();
const [addSiblingTag, setAddSiblingTag] = useState<string | undefined>();
useEffect(() => {
if (!value) {
const empty = createEmptyTree();
setTree(empty);
setSelectedId(empty.id);
setParseError(null);
lastSerialized.current = "";
return;
}
if (value === lastSerialized.current) return;
const result = parseXmlToTree(value);
if (result.tree) {
setTree(result.tree);
setSelectedId(result.tree.id);
setParseError(null);
lastSerialized.current = value;
} else if (result.error) {
setParseError(result.error);
}
}, [value]);
useEffect(() => {
const xml = serializeTreeToXml(tree);
lastSerialized.current = xml;
onChange?.(xml);
}, [tree, onChange]);
const objectNames = useMemo(
() => collectObjectNames(tree, config || null),
[tree, config]
);
const validationIssues = useMemo(
() => validateTree(tree, config || null),
[tree, config]
);
const selectedNode = useMemo(() => {
const result = findNodeWithParent(tree, selectedId);
return result.node || tree;
}, [tree, selectedId]);
const selectedIssue = validationIssues[selectedNode.id] || {
errors: [],
warnings: [],
};
const isStructureLocked = readOnly || readOnlyStructure;
const canEditAttributeValues = !readOnly && selectedNode.id !== tree.id;
const canEditAttributeKeys = canEditAttributeValues && !readOnlyStructure;
const canEditTagName = canEditAttributeKeys;
const canEditText = canEditAttributeValues && !readOnlyStructure;
const controlOptions = useMemo(() => {
if (!config?.controls) return { labeling: [], layout: [] };
const labeling = Object.entries(config.controls)
.filter(([, item]) => item.category === "labeling")
.map(([tag]) => ({ value: tag, label: getControlDisplayName(tag) }));
const layout = Object.entries(config.controls)
.filter(([, item]) => item.category === "layout")
.map(([tag]) => ({ value: tag, label: tag }));
return { labeling, layout };
}, [config]);
const objectOptions = useMemo(() => {
if (!config?.objects) return [];
return Object.keys(config.objects).map((tag) => ({
value: tag,
label: getObjectDisplayName(tag),
}));
}, [config]);
const tagOptions = useMemo(() => {
const options = [] as {
label: string;
options: { value: string; label: string }[];
}[];
options.push({
label: "容器",
options: [{ value: "View", label: "View" }],
});
if (objectOptions.length > 0) {
options.push({ label: "数据对象", options: objectOptions });
}
if (controlOptions.labeling.length > 0) {
options.push({ label: "标注控件", options: controlOptions.labeling });
}
if (controlOptions.layout.length > 0) {
options.push({ label: "布局标签", options: controlOptions.layout });
}
options.push({
label: "子标签",
options: CHILD_TAGS.map((tag) => ({ value: tag, label: tag })),
});
return options;
}, [objectOptions, controlOptions]);
const handleAddNode = (tag: string, mode: "child" | "sibling") => {
if (isStructureLocked) return;
const newNode = createNode(tag, config || null, objectNames);
if (mode === "child") {
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
children: [...current.children, newNode],
}))
);
setSelectedId(newNode.id);
return;
}
const { parent } = findNodeWithParent(tree, selectedNode.id);
if (!parent) return;
setTree((prev) =>
updateNode(prev, parent.id, (current) => {
const index = current.children.findIndex(
(child) => child.id === selectedNode.id
);
const children = [...current.children];
children.splice(index + 1, 0, newNode);
return { ...current, children };
})
);
setSelectedId(newNode.id);
};
const handleDeleteNode = () => {
if (isStructureLocked || selectedNode.id === tree.id) return;
const { node: nextTree } = removeNodeById(tree, selectedNode.id);
setTree(nextTree);
setSelectedId(nextTree.id);
};
const handleAttrKeyChange = (oldKey: string, newKey: string) => {
if (!canEditAttributeKeys) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => {
const attrs = { ...current.attrs };
const value = attrs[oldKey] ?? "";
delete attrs[oldKey];
if (newKey) {
attrs[newKey] = value;
}
return { ...current, attrs };
})
);
};
const handleAttrValueChange = (key: string, value: string) => {
if (!canEditAttributeValues) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
attrs: { ...current.attrs, [key]: value },
}))
);
};
const handleAddAttribute = () => {
if (!canEditAttributeKeys) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => {
const attrs = { ...current.attrs };
let index = 1;
let key = "attr";
while (attrs[key]) {
index += 1;
key = `attr${index}`;
}
attrs[key] = "";
return { ...current, attrs };
})
);
};
const handleRemoveAttribute = (key: string) => {
if (!canEditAttributeKeys) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => {
const attrs = { ...current.attrs };
delete attrs[key];
return { ...current, attrs };
})
);
};
const handleTagNameChange = (value: string) => {
if (!canEditTagName) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
tag: value || current.tag,
}))
);
};
const handleTextChange = (value: string) => {
if (!canEditText) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
text: value,
}))
);
};
const treeData: DataNode[] = useMemo(() => {
const build = (node: XmlNode): DataNode => {
const issue = validationIssues[node.id];
const hasError = issue?.errors?.length > 0;
const hasWarning = issue?.warnings?.length > 0;
return {
key: node.id,
title: (
<Space size={6}>
<span className={hasError ? "text-red-600" : undefined}>
{getNodeLabel(node)}
</span>
{hasError && <Tag color="red"></Tag>}
{!hasError && hasWarning && <Tag color="gold"></Tag>}
</Space>
),
children: node.children.map(build),
draggable: !isStructureLocked && node.id !== tree.id,
};
};
return [build(tree)];
}, [tree, validationIssues, isStructureLocked]);
const onDrop: TreeProps["onDrop"] = (info) => {
if (isStructureLocked) return;
const dragId = String(info.dragNode.key);
const dropId = String(info.node.key);
if (dragId === tree.id) return;
if (dragId === dropId) return;
const dragInfo = findNodeWithParent(tree, dragId);
const dropInfo = findNodeWithParent(tree, dropId);
if (!dragInfo.node || !dropInfo.node) return;
if (isDescendant(dragInfo.node, dropId)) {
return;
}
const removedResult = removeNodeById(tree, dragId);
let nextTree = removedResult.node;
const movingNode = removedResult.removed;
if (!movingNode) return;
if (!info.dropToGap) {
nextTree = updateNode(nextTree, dropId, (current) => ({
...current,
children: [...current.children, movingNode],
}));
} else {
const { parent } = findNodeWithParent(nextTree, dropId);
if (!parent) return;
nextTree = updateNode(nextTree, parent.id, (current) => {
const index = current.children.findIndex((child) => child.id === dropId);
const children = [...current.children];
const insertIndex = info.dropPosition > 0 ? index + 1 : index;
children.splice(insertIndex, 0, movingNode);
return { ...current, children };
});
}
setTree(nextTree);
setSelectedId(movingNode.id);
};
return (
<div className="grid grid-cols-2 gap-4" style={{ minHeight: 360 }}>
<Card
title={
<Space>
<BranchesOutlined />
<span></span>
</Space>
}
size="small"
style={{ height }}
bodyStyle={{ height: "100%", overflow: "auto" }}
>
{parseError && (
<Alert message={parseError} type="error" showIcon style={{ marginBottom: 8 }} />
)}
<Tree
treeData={treeData}
selectedKeys={[selectedId]}
onSelect={(keys) => {
if (keys.length > 0) setSelectedId(String(keys[0]));
}}
draggable={!isStructureLocked}
onDrop={onDrop}
blockNode
/>
</Card>
<Card title="节点配置" size="small" style={{ height }} bodyStyle={{ height: "100%", overflow: "auto" }}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<div>
<Title level={5} style={{ margin: 0 }}>
{selectedNode.tag}
</Title>
<Text type="secondary">{selectedNode.id}</Text>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
placeholder="添加子节点"
options={tagOptions}
value={addChildTag}
onChange={(value) => {
setAddChildTag(undefined);
handleAddNode(value, "child");
}}
disabled={isStructureLocked}
/>
<Select
placeholder="添加同级节点"
options={tagOptions}
value={addSiblingTag}
onChange={(value) => {
setAddSiblingTag(undefined);
handleAddNode(value, "sibling");
}}
disabled={isStructureLocked || selectedNode.id === tree.id}
/>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleDeleteNode}
disabled={isStructureLocked || selectedNode.id === tree.id}
>
</Button>
</div>
<div>
<Text></Text>
<Input
value={selectedNode.tag}
onChange={(e) => handleTagNameChange(e.target.value)}
disabled={!canEditTagName}
/>
</div>
{selectedNode.tag === "Style" || selectedNode.text !== undefined ? (
<div>
<Text></Text>
<Input.TextArea
value={selectedNode.text}
onChange={(e) => handleTextChange(e.target.value)}
rows={4}
disabled={!canEditText}
/>
</div>
) : null}
<div>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<Space align="center" style={{ width: "100%", justifyContent: "space-between" }}>
<Text></Text>
<Button size="small" onClick={handleAddAttribute} disabled={!canEditAttributeKeys} icon={<PlusOutlined />}>
</Button>
</Space>
{Object.entries(selectedNode.attrs).length === 0 && (
<Text type="secondary"></Text>
)}
{Object.entries(selectedNode.attrs).map(([key, value]) => (
<Space key={key} align="baseline" style={{ width: "100%" }}>
<Input
value={key}
onChange={(e) => handleAttrKeyChange(key, e.target.value)}
placeholder="属性名"
style={{ width: 120 }}
disabled={!canEditAttributeKeys}
/>
{key === "toName" ? (
<Select
mode="multiple"
style={{ flex: 1 }}
placeholder="选择对象"
value={value
.split(",")
.map((item) => item.trim())
.filter(Boolean)}
onChange={(values) => handleAttrValueChange(key, values.join(","))}
options={objectNames.map((name) => ({ value: name, label: name }))}
disabled={!canEditAttributeValues}
/>
) : (
<Input
value={value}
onChange={(e) => handleAttrValueChange(key, e.target.value)}
placeholder="属性值"
style={{ flex: 1 }}
disabled={!canEditAttributeValues}
/>
)}
<Button
size="small"
danger
onClick={() => handleRemoveAttribute(key)}
disabled={!canEditAttributeKeys}
>
</Button>
</Space>
))}
</Space>
</div>
{(selectedIssue.errors.length > 0 || selectedIssue.warnings.length > 0) && (
<div>
<Text strong></Text>
<div className="mt-2 space-y-1">
{selectedIssue.errors.map((err, index) => (
<Tag key={`err-${index}`} color="red">
{err}
</Tag>
))}
{selectedIssue.warnings.map((warn, index) => (
<Tag key={`warn-${index}`} color="gold">
{warn}
</Tag>
))}
</div>
</div>
)}
</Space>
</Card>
</div>
);
};
export default TemplateConfigurationTreeEditor;

View File

@@ -16,8 +16,8 @@ class AnnotationTemplate(Base):
description = Column(String(500), nullable=True, comment="模板描述") description = Column(String(500), nullable=True, comment="模板描述")
data_type = Column(String(50), nullable=False, comment="数据类型: image/text/audio/video/timeseries/pdf/chat/html/table") data_type = Column(String(50), nullable=False, comment="数据类型: image/text/audio/video/timeseries/pdf/chat/html/table")
labeling_type = Column(String(50), nullable=False, comment="标注类型: asr/ner/object-detection/等") labeling_type = Column(String(50), nullable=False, comment="标注类型: asr/ner/object-detection/等")
configuration = Column(JSON, nullable=False, comment="标注配置(包含labels定义等") configuration = Column(JSON, nullable=True, comment="标注配置(兼容字段,主配置为 label_config")
label_config = Column(Text, nullable=True, comment="Label Studio XML配置(内置模板预定义,自定义模板自动生成") label_config = Column(Text, nullable=True, comment="Label Studio XML配置(模板主配置")
style = Column(String(32), nullable=False, comment="样式配置: horizontal/vertical") style = Column(String(32), nullable=False, comment="样式配置: horizontal/vertical")
category = Column(String(50), default='custom', comment="模板分类: audio-speech/chat/computer-vision/nlp/等") category = Column(String(50), default='custom', comment="模板分类: audio-speech/chat/computer-vision/nlp/等")
built_in = Column(Boolean, default=False, comment="是否系统内置模板") built_in = Column(Boolean, default=False, comment="是否系统内置模板")

View File

@@ -23,11 +23,6 @@ objects:
required_attrs: [name, value] required_attrs: [name, value]
optional_attrs: [] optional_attrs: []
category: document category: document
ParagraphLabels:
description: "Display paragraphs with label support"
required_attrs: [name, value]
optional_attrs: []
category: text
Timeseries: Timeseries:
description: "Display timeseries data" description: "Display timeseries data"
required_attrs: [name, value] required_attrs: [name, value]
@@ -118,7 +113,7 @@ controls:
default: 3 default: 3
description: "Maximum depth of taxonomy tree" description: "Maximum depth of taxonomy tree"
requires_children: true requires_children: true
child_tag: Path child_tag: Choice
child_required_attrs: [value] child_required_attrs: [value]
category: labeling category: labeling
@@ -135,7 +130,7 @@ controls:
requires_children: true requires_children: true
child_tag: Choice child_tag: Choice
child_required_attrs: [value] child_required_attrs: [value]
category: layout category: labeling
List: List:
description: "List selection control" description: "List selection control"
@@ -150,11 +145,11 @@ controls:
requires_children: true requires_children: true
child_tag: Item child_tag: Item
child_required_attrs: [value] child_required_attrs: [value]
category: layout category: labeling
Filter: Filter:
description: "Filter control for annotation" description: "Filter control for annotation"
required_attrs: [name, toName] required_attrs: []
optional_attrs: optional_attrs:
required: required:
type: boolean type: boolean
@@ -163,7 +158,7 @@ controls:
Collapse: Collapse:
description: "Collapsible UI section" description: "Collapsible UI section"
required_attrs: [name] required_attrs: []
optional_attrs: optional_attrs:
collapsed: collapsed:
type: boolean type: boolean
@@ -173,18 +168,18 @@ controls:
Header: Header:
description: "Section header for UI grouping" description: "Section header for UI grouping"
required_attrs: [name] required_attrs: [value]
optional_attrs: optional_attrs:
level: size:
type: number type: number
default: 1 default: 3
description: "Header level (1-6)" description: "Header size"
requires_children: false requires_children: false
category: layout category: layout
Shortcut: Shortcut:
description: "Keyboard shortcut definition" description: "Keyboard shortcut definition"
required_attrs: [name, toName] required_attrs: []
optional_attrs: optional_attrs:
key: key:
type: string type: string
@@ -194,11 +189,8 @@ controls:
Style: Style:
description: "Custom style for annotation UI" description: "Custom style for annotation UI"
required_attrs: [name] required_attrs: []
optional_attrs: optional_attrs: {}
value:
type: string
description: "CSS style value"
requires_children: false requires_children: false
category: layout category: layout
@@ -247,23 +239,14 @@ controls:
child_required_attrs: [value] child_required_attrs: [value]
category: labeling category: labeling
Relation:
description: "Draw relation between objects"
required_attrs: [name, toName]
optional_attrs:
required:
type: boolean
requires_children: false
category: layout
Relations: Relations:
description: "Draw multiple relations between objects" description: "Draw multiple relations between objects"
required_attrs: [name, toName] required_attrs: []
optional_attrs: optional_attrs: {}
required: requires_children: true
type: boolean child_tag: Relation
requires_children: false child_required_attrs: [value]
category: layout category: labeling
Pairwise: Pairwise:
description: "Pairwise comparison control" description: "Pairwise comparison control"
@@ -272,7 +255,7 @@ controls:
required: required:
type: boolean type: boolean
requires_children: false requires_children: false
category: layout category: labeling
DateTime: DateTime:
description: "Date and time input" description: "Date and time input"
@@ -350,6 +333,15 @@ controls:
child_required_attrs: [value] child_required_attrs: [value]
category: labeling category: labeling
HyperTextLabels:
description: "Labels for hypertext entities"
required_attrs: [name, toName]
optional_attrs: [required]
requires_children: true
child_tag: Label
child_required_attrs: [value]
category: labeling
KeyPointLabels: KeyPointLabels:
description: "Keypoint annotations with labels" description: "Keypoint annotations with labels"
required_attrs: [name, toName] required_attrs: [name, toName]

View File

@@ -35,7 +35,7 @@ async def create_template(
- **description**: 模板描述(可选,最多500字符) - **description**: 模板描述(可选,最多500字符)
- **dataType**: 数据类型(必填) - **dataType**: 数据类型(必填)
- **labelingType**: 标注类型(必填) - **labelingType**: 标注类型(必填)
- **configuration**: 标注配置(必填,包含labels和objects - **labelConfig**: Label Studio XML 配置(必填
- **style**: 样式配置(默认horizontal) - **style**: 样式配置(默认horizontal)
- **category**: 模板分类(默认custom) - **category**: 模板分类(默认custom)
""" """

View File

@@ -45,7 +45,7 @@ class CreateAnnotationTemplateRequest(BaseModel):
description: Optional[str] = Field(None, max_length=500, description="模板描述") description: Optional[str] = Field(None, max_length=500, description="模板描述")
data_type: str = Field(alias="dataType", description="数据类型") data_type: str = Field(alias="dataType", description="数据类型")
labeling_type: str = Field(alias="labelingType", description="标注类型") labeling_type: str = Field(alias="labelingType", description="标注类型")
configuration: TemplateConfiguration = Field(..., description="标注配置") label_config: str = Field(alias="labelConfig", description="Label Studio XML 配置")
style: str = Field(default="horizontal", description="样式配置") style: str = Field(default="horizontal", description="样式配置")
category: str = Field(default="custom", description="模板分类") category: str = Field(default="custom", description="模板分类")
@@ -58,7 +58,7 @@ class UpdateAnnotationTemplateRequest(BaseModel):
description: Optional[str] = Field(None, max_length=500, description="模板描述") description: Optional[str] = Field(None, max_length=500, description="模板描述")
data_type: Optional[str] = Field(None, alias="dataType", description="数据类型") data_type: Optional[str] = Field(None, alias="dataType", description="数据类型")
labeling_type: Optional[str] = Field(None, alias="labelingType", description="标注类型") labeling_type: Optional[str] = Field(None, alias="labelingType", description="标注类型")
configuration: Optional[TemplateConfiguration] = Field(None, description="标注配置") label_config: Optional[str] = Field(None, alias="labelConfig", description="Label Studio XML 配置")
style: Optional[str] = Field(None, description="样式配置") style: Optional[str] = Field(None, description="样式配置")
category: Optional[str] = Field(None, description="模板分类") category: Optional[str] = Field(None, description="模板分类")
@@ -72,8 +72,8 @@ class AnnotationTemplateResponse(BaseModel):
description: Optional[str] = Field(None, description="模板描述") description: Optional[str] = Field(None, description="模板描述")
data_type: str = Field(alias="dataType", description="数据类型") data_type: str = Field(alias="dataType", description="数据类型")
labeling_type: str = Field(alias="labelingType", description="标注类型") labeling_type: str = Field(alias="labelingType", description="标注类型")
configuration: TemplateConfiguration = Field(..., description="标注配置") configuration: Optional[TemplateConfiguration] = Field(None, description="标注配置")
label_config: Optional[str] = Field(None, alias="labelConfig", description="生成的Label Studio XML配置") label_config: Optional[str] = Field(None, alias="labelConfig", description="Label Studio XML配置")
style: str = Field(..., description="样式配置") style: str = Field(..., description="样式配置")
category: str = Field(..., description="模板分类") category: str = Field(..., description="模板分类")
built_in: bool = Field(alias="builtIn", description="是否内置模板") built_in: bool = Field(alias="builtIn", description="是否内置模板")

View File

@@ -114,28 +114,20 @@ class AnnotationTemplateService:
Returns: Returns:
创建的模板响应 创建的模板响应
""" """
# 验证配置JSON label_config = request.label_config
config_dict = request.configuration.model_dump(mode='json', by_alias=False)
valid, error = LabelStudioConfigValidator.validate_configuration_json(config_dict)
if not valid:
raise HTTPException(status_code=400, detail=f"Invalid configuration: {error}")
# 生成Label Studio XML配置(用于验证,但不存储)
label_config = self.generate_label_studio_config(request.configuration)
# 验证生成的XML
valid, error = LabelStudioConfigValidator.validate_xml(label_config) valid, error = LabelStudioConfigValidator.validate_xml(label_config)
if not valid: if not valid:
raise HTTPException(status_code=400, detail=f"Generated XML is invalid: {error}") raise HTTPException(status_code=400, detail=f"Invalid labelConfig: {error}")
# 创建模板对象(不包含label_config字段) # 创建模板对象
template = AnnotationTemplate( template = AnnotationTemplate(
id=str(uuid4()), id=str(uuid4()),
name=request.name, name=request.name,
description=request.description, description=request.description,
data_type=request.data_type, data_type=request.data_type,
labeling_type=request.labeling_type, labeling_type=request.labeling_type,
configuration=config_dict, configuration=None,
label_config=label_config,
style=request.style, style=request.style,
category=request.category, category=request.category,
built_in=False, built_in=False,
@@ -280,24 +272,11 @@ class AnnotationTemplateService:
update_data = request.model_dump(exclude_unset=True, by_alias=False) update_data = request.model_dump(exclude_unset=True, by_alias=False)
for field, value in update_data.items(): for field, value in update_data.items():
if field == 'configuration' and value is not None: if field == "label_config" and value is not None:
# 验证配置JSON valid, error = LabelStudioConfigValidator.validate_xml(value)
config = value if isinstance(value, TemplateConfiguration) else TemplateConfiguration.model_validate(value)
config_dict = config.model_dump(mode='json', by_alias=False)
valid, error = LabelStudioConfigValidator.validate_configuration_json(config_dict)
if not valid: if not valid:
raise HTTPException(status_code=400, detail=f"Invalid configuration: {error}") raise HTTPException(status_code=400, detail=f"Invalid labelConfig: {error}")
setattr(template, field, value)
# 重新生成Label Studio XML配置(用于验证)
label_config = self.generate_label_studio_config(config)
# 验证生成的XML
valid, error = LabelStudioConfigValidator.validate_xml(label_config)
if not valid:
raise HTTPException(status_code=400, detail=f"Generated XML is invalid: {error}")
# 只更新configuration字段,不存储label_config
setattr(template, field, config_dict)
else: else:
setattr(template, field, value) setattr(template, field, value)
@@ -350,20 +329,17 @@ class AnnotationTemplateService:
Returns: Returns:
模板响应对象 模板响应对象
""" """
# 将配置JSON转换为TemplateConfiguration对象 config = None
if template.configuration:
try:
from typing import cast, Dict, Any from typing import cast, Dict, Any
config_dict = cast(Dict[str, Any], template.configuration) config_dict = cast(Dict[str, Any], template.configuration)
config = TemplateConfiguration(**config_dict) config = TemplateConfiguration(**config_dict)
except Exception:
config = None
# 优先使用预定义的 label_config,否则动态生成
if template.label_config:
label_config = template.label_config
else:
label_config = self.generate_label_studio_config(config)
# 使用model_validate从ORM对象创建响应对象
response = AnnotationTemplateResponse.model_validate(template) response = AnnotationTemplateResponse.model_validate(template)
response.configuration = config response.configuration = config
response.label_config = label_config # type: ignore response.label_config = template.label_config # type: ignore
return response return response

View File

@@ -1,7 +1,7 @@
""" """
Label Studio Configuration Validation Utilities Label Studio Configuration Validation Utilities
""" """
from typing import Dict, List, Tuple, Optional from typing import Dict, List, Tuple, Optional, Set
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from app.module.annotation.config import LabelStudioTagConfig from app.module.annotation.config import LabelStudioTagConfig
@@ -14,6 +14,23 @@ class LabelStudioConfigValidator:
"""获取标签配置实例""" """获取标签配置实例"""
return LabelStudioTagConfig() return LabelStudioTagConfig()
@staticmethod
def _get_required_attrs(tag_type: str, is_control: bool) -> List[str]:
config = LabelStudioConfigValidator._get_config()
tag_config = (
config.get_control_config(tag_type)
if is_control
else config.get_object_config(tag_type)
)
required_attrs = tag_config.get("required_attrs", []) if tag_config else []
return required_attrs if isinstance(required_attrs, list) else []
@staticmethod
def _get_control_category(tag_type: str) -> Optional[str]:
config = LabelStudioConfigValidator._get_config()
tag_config = config.get_control_config(tag_type) or {}
return tag_config.get("category")
@staticmethod @staticmethod
def validate_xml(xml_string: str) -> Tuple[bool, Optional[str]]: def validate_xml(xml_string: str) -> Tuple[bool, Optional[str]]:
""" """
@@ -33,22 +50,47 @@ class LabelStudioConfigValidator:
if root.tag != 'View': if root.tag != 'View':
return False, "Root element must be <View>" return False, "Root element must be <View>"
# 检查是否有对象定义
object_types = config.get_object_types() object_types = config.get_object_types()
objects = [elem for elem in root.iter() if elem is not root and elem.tag in object_types] control_types = config.get_control_types()
objects = [elem for elem in root.iter() if elem.tag in object_types]
controls = [elem for elem in root.iter() if elem.tag in control_types]
labeling_controls = [
control
for control in controls
if LabelStudioConfigValidator._get_control_category(control.tag) == "labeling"
]
if not objects: if not objects:
return False, "No data objects (Image, Text, etc.) found" return False, "No data objects (Image, Text, etc.) found"
# 检查是否有控件定义 if not labeling_controls:
control_types = config.get_control_types() return False, "No labeling controls found"
controls = [elem for elem in root.iter() if elem is not root and elem.tag in control_types]
if not controls:
return False, "No annotation controls found"
# 验证每个控件 object_names = {
obj.get("name") for obj in objects if obj.get("name")
}
# 校验对象必填属性
for obj in objects:
required_attrs = LabelStudioConfigValidator._get_required_attrs(
obj.tag, is_control=False
)
for attr in required_attrs:
if not obj.attrib.get(attr):
return False, f"Object {obj.tag} missing '{attr}' attribute"
if obj.attrib.get("value") and not obj.attrib.get("value", "").startswith("$"):
return False, "Object value must start with '$' (e.g., '$image')"
# 校验控件(布局类仅提示,不作为失败条件)
for control in controls: for control in controls:
valid, error = LabelStudioConfigValidator._validate_control(control) category = LabelStudioConfigValidator._get_control_category(control.tag)
if not valid: strict = category == "labeling"
valid, error = LabelStudioConfigValidator._validate_control(
control, object_names, strict
)
if not valid and strict:
return False, f"Control {control.tag}: {error}" return False, f"Control {control.tag}: {error}"
return True, None return True, None
@@ -59,7 +101,11 @@ class LabelStudioConfigValidator:
return False, f"Validation error: {str(e)}" return False, f"Validation error: {str(e)}"
@staticmethod @staticmethod
def _validate_control(control: ET.Element) -> Tuple[bool, Optional[str]]: def _validate_control(
control: ET.Element,
object_names: Set[str],
strict: bool
) -> Tuple[bool, Optional[str]]:
""" """
验证单个控件元素 验证单个控件元素
@@ -72,26 +118,37 @@ class LabelStudioConfigValidator:
config = LabelStudioConfigValidator._get_config() config = LabelStudioConfigValidator._get_config()
# 检查必需属性 # 检查必需属性
if 'name' not in control.attrib: required_attrs = LabelStudioConfigValidator._get_required_attrs(
return False, "Missing 'name' attribute" control.tag, is_control=True
)
for attr in required_attrs:
if not control.attrib.get(attr):
return (False, f"Missing '{attr}' attribute") if strict else (True, None)
if 'toName' not in control.attrib: # 校验 toName 指向对象
return False, "Missing 'toName' attribute" if strict and control.attrib.get("toName"):
to_names = [
name.strip()
for name in control.attrib.get("toName", "").split(",")
if name.strip()
]
invalid = [name for name in to_names if name not in object_names]
if invalid:
return False, f"toName references unknown object(s): {', '.join(invalid)}"
# 检查控件是否需要子元素 # 检查控件是否需要子元素
if config.requires_children(control.tag): if config.requires_children(control.tag):
child_tag = config.get_child_tag(control.tag) child_tag = config.get_child_tag(control.tag)
if not child_tag: if not child_tag:
return False, f"Configuration error: no child_tag defined for {control.tag}" return (False, f"Configuration error: no child_tag defined for {control.tag}") if strict else (True, None)
children = control.findall(child_tag) children = control.findall(child_tag)
if not children: if not children:
return False, f"{control.tag} must have at least one <{child_tag}> child" return (False, f"{control.tag} must have at least one <{child_tag}> child") if strict else (True, None)
# 检查每个子元素是否有value
for child in children: for child in children:
if 'value' not in child.attrib: if "value" not in child.attrib or not child.attrib.get("value"):
return False, f"{child_tag} missing 'value' attribute" return (False, f"{child_tag} missing 'value' attribute") if strict else (True, None)
return True, None return True, None

View File

@@ -15,8 +15,8 @@ CREATE TABLE IF NOT EXISTS t_dm_annotation_templates (
description VARCHAR(500) COMMENT '模板描述', description VARCHAR(500) COMMENT '模板描述',
data_type VARCHAR(50) NOT NULL COMMENT '数据类型: text/image/audio/video/pdf/timeseries/chat/html/table', data_type VARCHAR(50) NOT NULL COMMENT '数据类型: text/image/audio/video/pdf/timeseries/chat/html/table',
labeling_type VARCHAR(50) NOT NULL COMMENT '标注类型', labeling_type VARCHAR(50) NOT NULL COMMENT '标注类型',
configuration JSON NOT NULL COMMENT '标注配置(包含labels定义等', configuration JSON NULL COMMENT '标注配置(兼容字段,主配置为label_config',
label_config TEXT COMMENT 'Label Studio XML配置(内置模板预定义', label_config TEXT COMMENT 'Label Studio XML配置(模板主配置',
style VARCHAR(32) NOT NULL COMMENT '样式配置: horizontal/vertical', style VARCHAR(32) NOT NULL COMMENT '样式配置: horizontal/vertical',
category VARCHAR(50) DEFAULT 'custom' COMMENT '模板分类', category VARCHAR(50) DEFAULT 'custom' COMMENT '模板分类',
built_in BOOLEAN DEFAULT FALSE COMMENT '是否系统内置模板', built_in BOOLEAN DEFAULT FALSE COMMENT '是否系统内置模板',
@@ -776,19 +776,17 @@ INSERT INTO t_dm_annotation_templates (
'对文本进行分类,适用于情感分析、主题分类、垃圾邮件检测等场景。关联模型:BERT、RoBERTa、DistilBERT', '对文本进行分类,适用于情感分析、主题分类、垃圾邮件检测等场景。关联模型:BERT、RoBERTa、DistilBERT',
'text', 'text',
'text-classification', 'text-classification',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'sentiment', 'toName', 'text', 'type', 'Choices', 'options', JSON_ARRAY('Positive', 'Negative', 'Neutral'), 'required', true)
),
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
),
'<View> '<View>
<Text name="text" value="$text"/> <Text name="text" value="$text"/>
<Choices name="sentiment" toName="text" choice="single-radio" showInLine="true"> <View style="box-shadow: 2px 2px 5px #999; padding: 20px; margin-top: 2em; border-radius: 5px;">
<Header value="选择文本情感"/>
<Choices name="sentiment" toName="text" choice="single" showInline="true">
<Choice value="Positive"/> <Choice value="Positive"/>
<Choice value="Negative"/> <Choice value="Negative"/>
<Choice value="Neutral"/> <Choice value="Neutral"/>
</Choices> </Choices>
</View>
</View>', </View>',
'vertical', 'vertical',
'nlp', 'nlp',
@@ -806,12 +804,7 @@ INSERT INTO t_dm_annotation_templates (
'在文本中标注命名实体,适用于信息抽取、知识图谱构建、智能问答等场景。关联模型:BERT-NER、SpaCy、Flair', '在文本中标注命名实体,适用于信息抽取、知识图谱构建、智能问答等场景。关联模型:BERT-NER、SpaCy、Flair',
'text', 'text',
'ner', 'ner',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'label', 'toName', 'text', 'type', 'Labels', 'labels', JSON_ARRAY('PER', 'ORG', 'LOC', 'MISC'))
),
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
),
'<View> '<View>
<Labels name="label" toName="text"> <Labels name="label" toName="text">
<Label value="PER" background="red"/> <Label value="PER" background="red"/>
@@ -837,23 +830,16 @@ INSERT INTO t_dm_annotation_templates (
'标注实体间的关系,适用于知识图谱构建、信息抽取等场景。关联模型:BERT、GPT、OpenIE', '标注实体间的关系,适用于知识图谱构建、信息抽取等场景。关联模型:BERT、GPT、OpenIE',
'text', 'text',
'relation-extraction', 'relation-extraction',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'label', 'toName', 'text', 'type', 'Labels', 'labels', JSON_ARRAY('PER', 'ORG', 'LOC')),
JSON_OBJECT('fromName', 'relation', 'toName', 'text', 'type', 'Relations')
),
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
),
'<View> '<View>
<Relations> <Relations>
<Relation value="works_for"/> <Relation value="org:founded_by"/>
<Relation value="lives_in"/> <Relation value="org:founded"/>
<Relation value="located_in"/>
</Relations> </Relations>
<Labels name="label" toName="text"> <Labels name="label" toName="text">
<Label value="PER" background="red"/> <Label value="Organization" background="orange"/>
<Label value="ORG" background="darkorange"/> <Label value="Person" background="green"/>
<Label value="LOC" background="orange"/> <Label value="Datetime" background="blue"/>
</Labels> </Labels>
<Text name="text" value="$text"/> <Text name="text" value="$text"/>
</View>', </View>',
@@ -873,22 +859,15 @@ INSERT INTO t_dm_annotation_templates (
'翻译文本内容,适用于翻译质量评估、机器翻译后编辑等场景', '翻译文本内容,适用于翻译质量评估、机器翻译后编辑等场景',
'text', 'text',
'machine-translation', 'machine-translation',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'translation', 'toName', 'text', 'type', 'TextArea', 'required', true)
),
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
),
'<View> '<View>
<View style="display: grid; grid-template-columns: 1fr 1fr; grid-column-gap: 1em"> <View style="display: grid; grid-template: auto/1fr 1fr; column-gap: 1em">
<View>
<Header value="原文" /> <Header value="原文" />
<Text name="text" value="$text"/>
</View>
<View>
<Header value="翻译" /> <Header value="翻译" />
<TextArea name="translation" toName="text" rows="5" editable="true" maxSubmissions="1"/> <Text name="text" value="$text" />
</View> <TextArea name="translation" toName="text"
showSubmitButton="true" maxSubmissions="1" editable="true"
required="true" />
</View> </View>
</View>', </View>',
'vertical', 'vertical',
@@ -907,16 +886,14 @@ INSERT INTO t_dm_annotation_templates (
'编写文本摘要,适用于新闻摘要、文档摘要、会议纪要等场景', '编写文本摘要,适用于新闻摘要、文档摘要、会议纪要等场景',
'text', 'text',
'text-summarization', 'text-summarization',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'summary', 'toName', 'text', 'type', 'TextArea', 'required', true)
),
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
),
'<View> '<View>
<Header value="请阅读文本" />
<Text name="text" value="$text" /> <Text name="text" value="$text" />
<Header value="编写摘要"/> <Header value="提供一句话摘要" />
<TextArea name="summary" toName="text" rows="4" editable="true" maxSubmissions="1"/> <TextArea name="summary" toName="text"
showSubmitButton="true" maxSubmissions="1" editable="true"
required="true" />
</View>', </View>',
'vertical', 'vertical',
'nlp', 'nlp',
@@ -934,21 +911,15 @@ INSERT INTO t_dm_annotation_templates (
'基于上下文回答问题或标注答案,适用于阅读理解、智能客服等场景', '基于上下文回答问题或标注答案,适用于阅读理解、智能客服等场景',
'text', 'text',
'question-answering', 'question-answering',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'answer', 'toName', 'text', 'type', 'Labels', 'labels', JSON_ARRAY('Answer'))
),
'objects', JSON_ARRAY(
JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'),
JSON_OBJECT('name', 'question', 'type', 'Text', 'value', '$question')
)
),
'<View> '<View>
<Header value="请阅读文本" />
<Text name="text" value="$text" granularity="word"/>
<Header value="选择回答问题的文本片段" />
<Text name="question" value="$question"/> <Text name="question" value="$question"/>
<Labels name="answer" toName="text"> <Labels name="answer" toName="text">
<Label value="Answer" background="green"/> <Label value="Answer" maxUsage="1" background="red"/>
</Labels> </Labels>
<Text name="text" value="$text"/>
</View>', </View>',
'vertical', 'vertical',
'nlp', 'nlp',
@@ -966,31 +937,16 @@ INSERT INTO t_dm_annotation_templates (
'使用层级分类法对文本进行分类,适用于电商商品分类、文档归档等场景', '使用层级分类法对文本进行分类,适用于电商商品分类、文档归档等场景',
'text', 'text',
'taxonomy', 'taxonomy',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'taxonomy', 'toName', 'text', 'type', 'Taxonomy')
),
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
),
'<View> '<View>
<Text name="text" value="$text"/> <Text name="text" value="$text"/>
<Taxonomy name="taxonomy" toName="text"> <Taxonomy name="taxonomy" toName="text">
<Choice value="Archaea"> <Choice value="Archaea" />
<Choice value="Euryarchaeota"/> <Choice value="Bacteria" />
<Choice value="Crenarchaeota"/>
</Choice>
<Choice value="Bacteria">
<Choice value="Actinobacteria"/>
<Choice value="Proteobacteria"/>
</Choice>
<Choice value="Eukarya"> <Choice value="Eukarya">
<Choice value="Animalia"> <Choice value="Human" />
<Choice value="Chordata"> <Choice value="Oppossum" />
<Choice value="Mammalia"/> <Choice value="Extraterrestial" />
<Choice value="Aves"/>
</Choice>
</Choice>
<Choice value="Plantae"/>
</Choice> </Choice>
</Taxonomy> </Taxonomy>
</View>', </View>',
@@ -1014,16 +970,7 @@ INSERT INTO t_dm_annotation_templates (
'收集人类偏好用于RLHF训练,对两个LLM响应进行排名', '收集人类偏好用于RLHF训练,对两个LLM响应进行排名',
'text', 'text',
'rlhf-preference', 'rlhf-preference',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'preference', 'toName', 'prompt', 'type', 'Pairwise')
),
'objects', JSON_ARRAY(
JSON_OBJECT('name', 'prompt', 'type', 'Text', 'value', '$prompt'),
JSON_OBJECT('name', 'response1', 'type', 'Text', 'value', '$response1'),
JSON_OBJECT('name', 'response2', 'type', 'Text', 'value', '$response2')
)
),
'<View> '<View>
<Style> <Style>
.prompt-box { background: #f5f5f5; padding: 15px; border-radius: 8px; margin-bottom: 20px; } .prompt-box { background: #f5f5f5; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
@@ -1060,17 +1007,7 @@ INSERT INTO t_dm_annotation_templates (
'对LLM生成的响应进行多维度评分', '对LLM生成的响应进行多维度评分',
'text', 'text',
'llm-grading', 'llm-grading',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'relevance', 'toName', 'response', 'type', 'Rating'),
JSON_OBJECT('fromName', 'coherence', 'toName', 'response', 'type', 'Rating'),
JSON_OBJECT('fromName', 'fluency', 'toName', 'response', 'type', 'Rating')
),
'objects', JSON_ARRAY(
JSON_OBJECT('name', 'prompt', 'type', 'Text', 'value', '$prompt'),
JSON_OBJECT('name', 'response', 'type', 'Text', 'value', '$response')
)
),
'<View> '<View>
<Header value="Prompt"/> <Header value="Prompt"/>
<Text name="prompt" value="$prompt"/> <Text name="prompt" value="$prompt"/>
@@ -1099,12 +1036,7 @@ INSERT INTO t_dm_annotation_templates (
'为LLM监督微调收集高质量指令-响应对', '为LLM监督微调收集高质量指令-响应对',
'text', 'text',
'sft', 'sft',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'response', 'toName', 'instruction', 'type', 'TextArea', 'required', true)
),
'objects', JSON_ARRAY(JSON_OBJECT('name', 'instruction', 'type', 'Text', 'value', '$instruction'))
),
'<View> '<View>
<Header value="指令"/> <Header value="指令"/>
<Text name="instruction" value="$instruction"/> <Text name="instruction" value="$instruction"/>
@@ -1131,16 +1063,7 @@ INSERT INTO t_dm_annotation_templates (
'对两个项目进行成对比较分类', '对两个项目进行成对比较分类',
'text', 'text',
'pairwise-classification', 'pairwise-classification',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'comparison', 'toName', 'text', 'type', 'Pairwise')
),
'objects', JSON_ARRAY(
JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$prompt'),
JSON_OBJECT('name', 'option1', 'type', 'Text', 'value', '$option1'),
JSON_OBJECT('name', 'option2', 'type', 'Text', 'value', '$option2')
)
),
'<View> '<View>
<Text name="text" value="$prompt"/> <Text name="text" value="$prompt"/>
<Pairwise name="comparison" toName="text" leftText="选项A" rightText="选项B"> <Pairwise name="comparison" toName="text" leftText="选项A" rightText="选项B">
@@ -1164,15 +1087,7 @@ INSERT INTO t_dm_annotation_templates (
'对搜索结果进行相关性排名,适用于搜索引擎优化、信息检索等场景', '对搜索结果进行相关性排名,适用于搜索引擎优化、信息检索等场景',
'text', 'text',
'serp-ranking', 'serp-ranking',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'relevance', 'toName', 'result', 'type', 'Rating')
),
'objects', JSON_ARRAY(
JSON_OBJECT('name', 'query', 'type', 'Text', 'value', '$query'),
JSON_OBJECT('name', 'result', 'type', 'Text', 'value', '$result')
)
),
'<View> '<View>
<Header value="搜索查询"/> <Header value="搜索查询"/>
<Text name="query" value="$query"/> <Text name="query" value="$query"/>
@@ -1438,12 +1353,7 @@ INSERT INTO t_dm_annotation_templates (
'在文本中标注共指关系和实体链接', '在文本中标注共指关系和实体链接',
'text', 'text',
'coreference-resolution', 'coreference-resolution',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'label', 'toName', 'text', 'type', 'Labels', 'labels', JSON_ARRAY('Noun', 'Pronoun'))
),
'objects', JSON_ARRAY(JSON_OBJECT('name', 'text', 'type', 'Text', 'value', '$text'))
),
'<View> '<View>
<Labels name="label" toName="text"> <Labels name="label" toName="text">
<Label value="Noun" background="red"/> <Label value="Noun" background="red"/>
@@ -1467,13 +1377,7 @@ INSERT INTO t_dm_annotation_templates (
'构建任务导向对话系统,选择对话意图并提取槽实体', '构建任务导向对话系统,选择对话意图并提取槽实体',
'text', 'text',
'slot-filling', 'slot-filling',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'entity_slot', 'toName', 'dialogue', 'type', 'ParagraphLabels', 'labels', JSON_ARRAY('Person', 'Organization', 'Location', 'Datetime', 'Quantity')),
JSON_OBJECT('fromName', 'intent', 'toName', 'dialogue', 'type', 'Choices', 'options', JSON_ARRAY('Greeting', 'Customer request', 'Small talk'))
),
'objects', JSON_ARRAY(JSON_OBJECT('name', 'dialogue', 'type', 'Paragraphs', 'value', '$dialogue'))
),
'<View> '<View>
<ParagraphLabels name="entity_slot" toName="dialogue"> <ParagraphLabels name="entity_slot" toName="dialogue">
<Label value="Person" /> <Label value="Person" />
@@ -1505,12 +1409,7 @@ INSERT INTO t_dm_annotation_templates (
'通过生成下一个对话响应来收集聊天机器人训练数据', '通过生成下一个对话响应来收集聊天机器人训练数据',
'text', 'text',
'response-generation', 'response-generation',
JSON_OBJECT( NULL,
'labels', JSON_ARRAY(
JSON_OBJECT('fromName', 'response', 'toName', 'chat', 'type', 'TextArea', 'required', true)
),
'objects', JSON_ARRAY(JSON_OBJECT('name', 'chat', 'type', 'Paragraphs', 'value', '$dialogue'))
),
'<View> '<View>
<Paragraphs name="chat" value="$dialogue" layout="dialogue" /> <Paragraphs name="chat" value="$dialogue" layout="dialogue" />
<Header value="提供响应" /> <Header value="提供响应" />