feat(annotation): 文件版本更新时支持保留标注记录(位置偏移+文字匹配迁移)

新增 AnnotationMigrator 迁移算法,在 TEXT 类型数据集的文件版本更新时,
可选通过 difflib 位置偏移映射和文字二次匹配将旧版本标注迁移到新版本上。
前端版本切换对话框增加"保留标注"复选框(仅 TEXT 类型显示),后端 API
增加 preserveAnnotations 参数,完全向后兼容。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 19:42:59 +08:00
parent 7d5a809772
commit 807c2289e2
6 changed files with 499 additions and 82 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Card, List, Spin, Typography, Tag, Empty } from "antd";
import { App, Button, Card, Checkbox, List, Spin, Typography, Tag, Empty } from "antd";
import { LeftOutlined, ReloadOutlined, SaveOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
import { useNavigate, useParams } from "react-router";
@@ -11,6 +11,7 @@ import {
checkFileVersionUsingGet,
applyNewVersionUsingPost,
type FileVersionCheckResponse,
type UseNewVersionResponse,
} from "../annotation.api";
import { AnnotationResultStatus } from "../annotation.model";
@@ -242,6 +243,7 @@ export default function LabelStudioTextEditor() {
} | null>(null);
const savedSnapshotsRef = useRef<Record<string, string>>({});
const pendingAutoAdvanceRef = useRef(false);
const preserveAnnotationsRef = useRef(true);
const [loadingProject, setLoadingProject] = useState(true);
const [loadingTasks, setLoadingTasks] = useState(false);
@@ -594,18 +596,31 @@ export default function LabelStudioTextEditor() {
const handleUseNewVersion = useCallback(async () => {
if (!selectedFileId) return;
// Reset ref to default before opening dialog
preserveAnnotationsRef.current = true;
modal.confirm({
title: "确认使用新版本",
content: (
<div className="flex flex-col gap-2">
<Typography.Text>
使使
使使
</Typography.Text>
{fileVersionInfo && (
<Typography.Text type="secondary">
: {fileVersionInfo.annotationFileVersion}: {fileVersionInfo.currentFileVersion}
</Typography.Text>
)}
{isTextProject && (
<Checkbox
defaultChecked={true}
onChange={(e) => {
preserveAnnotationsRef.current = e.target.checked;
}}
>
</Checkbox>
)}
</div>
),
okText: "确认",
@@ -615,8 +630,19 @@ export default function LabelStudioTextEditor() {
if (!projectId || !selectedFileId) return;
setUsingNewVersion(true);
try {
await applyNewVersionUsingPost(projectId, selectedFileId);
message.success("已使用新版本并清空标注");
const resp = (await applyNewVersionUsingPost(
projectId,
selectedFileId,
preserveAnnotationsRef.current,
)) as ApiResponse<UseNewVersionResponse>;
const data = resp?.data;
if (data?.migratedCount != null) {
message.success(
`已切换到新版本,${data.migratedCount} 条标注已迁移,${data.failedCount ?? 0} 条无法迁移`,
);
} else {
message.success("已使用新版本并清空标注");
}
setFileVersionInfo(null);
await loadTasks({ mode: "reset" });
await initEditorForFile(selectedFileId);
@@ -628,7 +654,7 @@ export default function LabelStudioTextEditor() {
}
},
});
}, [modal, message, projectId, selectedFileId, fileVersionInfo, loadTasks, initEditorForFile]);
}, [modal, message, projectId, selectedFileId, fileVersionInfo, isTextProject, loadTasks, initEditorForFile]);
const advanceAfterSave = useCallback(async (fileId: string, segmentIndex?: number) => {
if (!fileId) return;

View File

@@ -1,5 +1,5 @@
import { get, post, put, del, download } from "@/utils/request";
import { get, post, put, del, download } from "@/utils/request";
// 导出格式类型
export type ExportFormat = "json" | "jsonl" | "csv" | "coco" | "yolo";
@@ -42,26 +42,26 @@ export function stopAnnotationOperatorTaskByIdUsingPost(taskId: string) {
export function downloadAnnotationOperatorTaskResultUsingGet(taskId: string, filename?: string) {
return download(`/api/annotation/operator-tasks/${taskId}/download`, null, filename);
}
export function deleteAnnotationTaskByIdUsingDelete(mappingId: string) {
// Backend expects mapping UUID as path parameter
return del(`/api/annotation/project/${mappingId}`);
}
export function getAnnotationTaskByIdUsingGet(taskId: string) {
return get(`/api/annotation/project/${taskId}`);
}
export function deleteAnnotationTaskByIdUsingDelete(mappingId: string) {
// Backend expects mapping UUID as path parameter
return del(`/api/annotation/project/${mappingId}`);
}
export function getAnnotationTaskByIdUsingGet(taskId: string) {
return get(`/api/annotation/project/${taskId}`);
}
export function updateAnnotationTaskByIdUsingPut(taskId: string, data: RequestPayload) {
return put(`/api/annotation/project/${taskId}`, data);
}
// 标签配置管理
export function getTagConfigUsingGet() {
return get("/api/annotation/tags/config");
}
// 标注模板管理
// 标签配置管理
export function getTagConfigUsingGet() {
return get("/api/annotation/tags/config");
}
// 标注模板管理
export function queryAnnotationTemplatesUsingGet(params?: RequestParams) {
return get("/api/annotation/template", params);
}
@@ -69,33 +69,33 @@ export function queryAnnotationTemplatesUsingGet(params?: RequestParams) {
export function createAnnotationTemplateUsingPost(data: RequestPayload) {
return post("/api/annotation/template", data);
}
export function updateAnnotationTemplateByIdUsingPut(
templateId: string | number,
data: RequestPayload
) {
return put(`/api/annotation/template/${templateId}`, data);
}
export function deleteAnnotationTemplateByIdUsingDelete(
templateId: string | number
) {
return del(`/api/annotation/template/${templateId}`);
}
// =====================
// Label Studio Editor(内嵌版)
// =====================
export function getEditorProjectInfoUsingGet(projectId: string) {
return get(`/api/annotation/editor/projects/${projectId}`);
}
export function deleteAnnotationTemplateByIdUsingDelete(
templateId: string | number
) {
return del(`/api/annotation/template/${templateId}`);
}
// =====================
// Label Studio Editor(内嵌版)
// =====================
export function getEditorProjectInfoUsingGet(projectId: string) {
return get(`/api/annotation/editor/projects/${projectId}`);
}
export function listEditorTasksUsingGet(projectId: string, params?: RequestParams) {
return get(`/api/annotation/editor/projects/${projectId}/tasks`, params);
}
export function getEditorTaskUsingGet(
projectId: string,
fileId: string,
@@ -111,7 +111,7 @@ export function getEditorTaskSegmentUsingGet(
) {
return get(`/api/annotation/editor/projects/${projectId}/tasks/${fileId}/segments`, params);
}
export function upsertEditorAnnotationUsingPut(
projectId: string,
fileId: string,
@@ -141,40 +141,49 @@ export interface UseNewVersionResponse {
previousFileVersion: number | null;
currentFileVersion: number;
message: string;
migratedCount?: number;
failedCount?: number;
}
export function applyNewVersionUsingPost(projectId: string, fileId: string) {
return post(`/api/annotation/editor/projects/${projectId}/files/${fileId}/use-new-version`, {});
export function applyNewVersionUsingPost(
projectId: string,
fileId: string,
preserveAnnotations: boolean = false,
) {
return post(
`/api/annotation/editor/projects/${projectId}/files/${fileId}/use-new-version`,
{ preserveAnnotations },
);
}
// =====================
// 标注数据导出
// =====================
export interface ExportStatsResponse {
projectId: string;
projectName: string;
totalFiles: number;
annotatedFiles: number;
exportFormat: string;
}
export function getExportStatsUsingGet(projectId: string) {
return get(`/api/annotation/export/projects/${projectId}/stats`);
}
export function downloadAnnotationsUsingGet(
projectId: string,
format: ExportFormat = "json",
onlyAnnotated: boolean = true,
includeData: boolean = false,
filename?: string
) {
const params = new URLSearchParams({
format,
only_annotated: String(onlyAnnotated),
include_data: String(includeData),
});
return download(`/api/annotation/export/projects/${projectId}/download?${params.toString()}`, null, filename);
}
// =====================
// 标注数据导出
// =====================
export interface ExportStatsResponse {
projectId: string;
projectName: string;
totalFiles: number;
annotatedFiles: number;
exportFormat: string;
}
export function getExportStatsUsingGet(projectId: string) {
return get(`/api/annotation/export/projects/${projectId}/stats`);
}
export function downloadAnnotationsUsingGet(
projectId: string,
format: ExportFormat = "json",
onlyAnnotated: boolean = true,
includeData: boolean = false,
filename?: string
) {
const params = new URLSearchParams({
format,
only_annotated: String(onlyAnnotated),
include_data: String(includeData),
});
return download(`/api/annotation/export/projects/${projectId}/download?${params.toString()}`, null, filename);
}