You've already forked DataMate
feat(annotation): 添加分段标注统计和进度跟踪功能
- 新增 SegmentStats 类型定义用于分段统计 - 实现分段标注进度计算和缓存机制 - 添加标注任务状态判断逻辑支持分段模式 - 集成分段统计数据显示到任务列表界面 - 实现分段总数自动计算和验证功能 - 扩展标注状态枚举支持进行中标注状态 - 优化任务选择逻辑基于分段完成状态 - 添加分段统计数据预加载和同步机制
This commit is contained in:
@@ -28,6 +28,7 @@ type EditorTaskListItem = {
|
||||
hasAnnotation: boolean;
|
||||
annotationUpdatedAt?: string | null;
|
||||
annotationStatus?: AnnotationResultStatus | null;
|
||||
segmentStats?: SegmentStats;
|
||||
};
|
||||
|
||||
type LsfMessage = {
|
||||
@@ -45,6 +46,11 @@ type SegmentInfo = {
|
||||
chunkIndex: number;
|
||||
};
|
||||
|
||||
type SegmentStats = {
|
||||
done: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
type ApiResponse<T> = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
@@ -136,6 +142,16 @@ const isAnnotationResultEmpty = (annotation?: Record<string, unknown>) => {
|
||||
};
|
||||
|
||||
const resolveTaskStatusMeta = (item: EditorTaskListItem) => {
|
||||
const segmentSummary = resolveSegmentSummary(item);
|
||||
if (segmentSummary) {
|
||||
if (segmentSummary.done >= segmentSummary.total) {
|
||||
return { text: "已标注", type: "success" as const };
|
||||
}
|
||||
if (segmentSummary.done > 0) {
|
||||
return { text: "标注中", type: "warning" as const };
|
||||
}
|
||||
return { text: "未标注", type: "secondary" as const };
|
||||
}
|
||||
if (!item.hasAnnotation) {
|
||||
return { text: "未标注", type: "secondary" as const };
|
||||
}
|
||||
@@ -145,6 +161,9 @@ const resolveTaskStatusMeta = (item: EditorTaskListItem) => {
|
||||
if (item.annotationStatus === AnnotationResultStatus.NOT_APPLICABLE) {
|
||||
return { text: NOT_APPLICABLE_LABEL, type: "warning" as const };
|
||||
}
|
||||
if (item.annotationStatus === AnnotationResultStatus.IN_PROGRESS) {
|
||||
return { text: "标注中", type: "warning" as const };
|
||||
}
|
||||
return { text: "已标注", type: "success" as const };
|
||||
};
|
||||
|
||||
@@ -184,6 +203,25 @@ const buildAnnotationSnapshot = (annotation?: Record<string, unknown>) => {
|
||||
const buildSnapshotKey = (fileId: string, segmentIndex?: number) =>
|
||||
`${fileId}::${segmentIndex ?? "full"}`;
|
||||
|
||||
const buildSegmentStats = (segmentList?: SegmentInfo[] | null): SegmentStats | null => {
|
||||
if (!Array.isArray(segmentList) || segmentList.length === 0) return null;
|
||||
const total = segmentList.length;
|
||||
const done = segmentList.reduce((count, seg) => count + (seg.hasAnnotation ? 1 : 0), 0);
|
||||
return { done, total };
|
||||
};
|
||||
|
||||
const normalizeSegmentStats = (stats?: SegmentStats | null): SegmentStats | null => {
|
||||
if (!stats) return null;
|
||||
const total = Number(stats.total);
|
||||
const done = Number(stats.done);
|
||||
if (!Number.isFinite(total) || total <= 0) return null;
|
||||
const safeDone = Math.min(Math.max(done, 0), total);
|
||||
return { done: safeDone, total };
|
||||
};
|
||||
|
||||
const resolveSegmentSummary = (item: EditorTaskListItem) =>
|
||||
normalizeSegmentStats(item.segmentStats);
|
||||
|
||||
const mergeTaskItems = (base: EditorTaskListItem[], next: EditorTaskListItem[]) => {
|
||||
if (next.length === 0) return base;
|
||||
const seen = new Set(base.map((item) => item.fileId));
|
||||
@@ -234,6 +272,9 @@ export default function LabelStudioTextEditor() {
|
||||
const exportCheckSeqRef = useRef(0);
|
||||
const savedSnapshotsRef = useRef<Record<string, string>>({});
|
||||
const pendingAutoAdvanceRef = useRef(false);
|
||||
const segmentStatsCacheRef = useRef<Record<string, SegmentStats>>({});
|
||||
const segmentStatsSeqRef = useRef(0);
|
||||
const segmentStatsLoadingRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const [loadingProject, setLoadingProject] = useState(true);
|
||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||
@@ -276,6 +317,70 @@ export default function LabelStudioTextEditor() {
|
||||
win.postMessage({ type, payload }, origin);
|
||||
}, [origin]);
|
||||
|
||||
const applySegmentStats = useCallback((fileId: string, stats: SegmentStats | null) => {
|
||||
if (!fileId) return;
|
||||
const normalized = normalizeSegmentStats(stats);
|
||||
setTasks((prev) =>
|
||||
prev.map((item) =>
|
||||
item.fileId === fileId
|
||||
? { ...item, segmentStats: normalized || undefined }
|
||||
: item
|
||||
)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const updateSegmentStatsCache = useCallback((fileId: string, stats: SegmentStats | null) => {
|
||||
if (!fileId) return;
|
||||
const normalized = normalizeSegmentStats(stats);
|
||||
if (normalized) {
|
||||
segmentStatsCacheRef.current[fileId] = normalized;
|
||||
} else {
|
||||
delete segmentStatsCacheRef.current[fileId];
|
||||
}
|
||||
applySegmentStats(fileId, normalized);
|
||||
}, [applySegmentStats]);
|
||||
|
||||
const fetchSegmentStatsForFile = useCallback(async (fileId: string, seq: number) => {
|
||||
if (!projectId || !fileId) return;
|
||||
if (segmentStatsCacheRef.current[fileId] || segmentStatsLoadingRef.current.has(fileId)) return;
|
||||
segmentStatsLoadingRef.current.add(fileId);
|
||||
try {
|
||||
const resp = (await getEditorTaskUsingGet(projectId, fileId, {
|
||||
segmentIndex: 0,
|
||||
})) as ApiResponse<EditorTaskResponse>;
|
||||
if (segmentStatsSeqRef.current !== seq) return;
|
||||
const data = resp?.data;
|
||||
if (!data?.segmented) return;
|
||||
const stats = buildSegmentStats(data.segments);
|
||||
if (!stats) return;
|
||||
segmentStatsCacheRef.current[fileId] = stats;
|
||||
applySegmentStats(fileId, stats);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
segmentStatsLoadingRef.current.delete(fileId);
|
||||
}
|
||||
}, [applySegmentStats, projectId]);
|
||||
|
||||
const prefetchSegmentStats = useCallback((items: EditorTaskListItem[]) => {
|
||||
if (!projectId) return;
|
||||
const fileIds = items
|
||||
.map((item) => item.fileId)
|
||||
.filter((fileId) => fileId && !segmentStatsCacheRef.current[fileId]);
|
||||
if (fileIds.length === 0) return;
|
||||
const seq = segmentStatsSeqRef.current;
|
||||
let cursor = 0;
|
||||
const workerCount = Math.min(3, fileIds.length);
|
||||
const runWorker = async () => {
|
||||
while (cursor < fileIds.length && segmentStatsSeqRef.current === seq) {
|
||||
const fileId = fileIds[cursor];
|
||||
cursor += 1;
|
||||
await fetchSegmentStatsForFile(fileId, seq);
|
||||
}
|
||||
};
|
||||
void Promise.all(Array.from({ length: workerCount }, () => runWorker()));
|
||||
}, [fetchSegmentStatsForFile, projectId]);
|
||||
|
||||
const confirmEmptyAnnotationStatus = useCallback(() => {
|
||||
return new Promise<AnnotationResultStatus | null>((resolve) => {
|
||||
let resolved = false;
|
||||
@@ -327,8 +432,13 @@ export default function LabelStudioTextEditor() {
|
||||
}, [message, projectId]);
|
||||
|
||||
const updateTaskSelection = useCallback((items: EditorTaskListItem[]) => {
|
||||
const isCompleted = (item: EditorTaskListItem) => {
|
||||
const summary = resolveSegmentSummary(item);
|
||||
if (summary) return summary.done >= summary.total;
|
||||
return item.hasAnnotation;
|
||||
};
|
||||
const defaultFileId =
|
||||
items.find((item) => !item.hasAnnotation)?.fileId || items[0]?.fileId || "";
|
||||
items.find((item) => !isCompleted(item))?.fileId || items[0]?.fileId || "";
|
||||
setSelectedFileId((prev) => {
|
||||
if (prev && items.some((item) => item.fileId === prev)) return prev;
|
||||
return defaultFileId;
|
||||
@@ -385,6 +495,9 @@ export default function LabelStudioTextEditor() {
|
||||
if (mode === "reset") {
|
||||
prefetchSeqRef.current += 1;
|
||||
setPrefetching(false);
|
||||
segmentStatsSeqRef.current += 1;
|
||||
segmentStatsCacheRef.current = {};
|
||||
segmentStatsLoadingRef.current = new Set();
|
||||
}
|
||||
if (mode === "append") {
|
||||
setLoadingMore(true);
|
||||
@@ -469,13 +582,16 @@ export default function LabelStudioTextEditor() {
|
||||
? resolveSegmentIndex(data.currentSegmentIndex) ?? 0
|
||||
: undefined;
|
||||
if (data?.segmented) {
|
||||
const stats = buildSegmentStats(data.segments);
|
||||
setSegmented(true);
|
||||
setSegments(data.segments || []);
|
||||
setCurrentSegmentIndex(segmentIndex ?? 0);
|
||||
updateSegmentStatsCache(fileId, stats);
|
||||
} else {
|
||||
setSegmented(false);
|
||||
setSegments([]);
|
||||
setCurrentSegmentIndex(0);
|
||||
updateSegmentStatsCache(fileId, null);
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
@@ -535,7 +651,7 @@ export default function LabelStudioTextEditor() {
|
||||
} finally {
|
||||
if (seq === initSeqRef.current) setLoadingTaskDetail(false);
|
||||
}
|
||||
}, [iframeReady, message, postToIframe, project, projectId]);
|
||||
}, [iframeReady, message, postToIframe, project, projectId, updateSegmentStatsCache]);
|
||||
|
||||
const advanceAfterSave = useCallback(async (fileId: string, segmentIndex?: number) => {
|
||||
if (!fileId) return;
|
||||
@@ -643,13 +759,13 @@ export default function LabelStudioTextEditor() {
|
||||
|
||||
// 分段模式下更新当前段落的标注状态
|
||||
if (segmented && segmentIndex !== undefined) {
|
||||
setSegments((prev) =>
|
||||
prev.map((seg) =>
|
||||
seg.idx === segmentIndex
|
||||
? { ...seg, hasAnnotation: true }
|
||||
: seg
|
||||
)
|
||||
const nextSegments = segments.map((seg) =>
|
||||
seg.idx === segmentIndex
|
||||
? { ...seg, hasAnnotation: true }
|
||||
: seg
|
||||
);
|
||||
setSegments(nextSegments);
|
||||
updateSegmentStatsCache(String(fileId), buildSegmentStats(nextSegments));
|
||||
}
|
||||
if (options?.autoAdvance) {
|
||||
await advanceAfterSave(String(fileId), segmentIndex);
|
||||
@@ -669,8 +785,10 @@ export default function LabelStudioTextEditor() {
|
||||
message,
|
||||
projectId,
|
||||
segmented,
|
||||
segments,
|
||||
selectedFileId,
|
||||
tasks,
|
||||
updateSegmentStatsCache,
|
||||
]);
|
||||
|
||||
const requestExportForCheck = useCallback(() => {
|
||||
@@ -834,6 +952,9 @@ export default function LabelStudioTextEditor() {
|
||||
setSegments([]);
|
||||
setCurrentSegmentIndex(0);
|
||||
savedSnapshotsRef.current = {};
|
||||
segmentStatsSeqRef.current += 1;
|
||||
segmentStatsCacheRef.current = {};
|
||||
segmentStatsLoadingRef.current = new Set();
|
||||
if (exportCheckRef.current?.timer) {
|
||||
window.clearTimeout(exportCheckRef.current.timer);
|
||||
}
|
||||
@@ -847,6 +968,12 @@ export default function LabelStudioTextEditor() {
|
||||
loadTasks({ mode: "reset" });
|
||||
}, [project?.supported, loadTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!segmented) return;
|
||||
if (tasks.length === 0) return;
|
||||
prefetchSegmentStats(tasks);
|
||||
}, [prefetchSegmentStats, segmented, tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFileId) return;
|
||||
initEditorForFile(selectedFileId);
|
||||
@@ -1097,6 +1224,7 @@ export default function LabelStudioTextEditor() {
|
||||
dataSource={tasks}
|
||||
loadMore={loadMoreNode}
|
||||
renderItem={(item) => {
|
||||
const segmentSummary = resolveSegmentSummary(item);
|
||||
const statusMeta = resolveTaskStatusMeta(item);
|
||||
return (
|
||||
<List.Item
|
||||
@@ -1110,18 +1238,25 @@ export default function LabelStudioTextEditor() {
|
||||
onClick={() => setSelectedFileId(item.fileId)}
|
||||
>
|
||||
<div className="flex flex-col w-full gap-1">
|
||||
<Typography.Text ellipsis style={{ fontSize: 13 }}>
|
||||
{item.fileName}
|
||||
</Typography.Text>
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.Text type={statusMeta.type} style={{ fontSize: 11 }}>
|
||||
{statusMeta.text}
|
||||
<Typography.Text ellipsis style={{ fontSize: 13 }}>
|
||||
{item.fileName}
|
||||
</Typography.Text>
|
||||
{item.annotationUpdatedAt && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
|
||||
{item.annotationUpdatedAt}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography.Text type={statusMeta.type} style={{ fontSize: 11 }}>
|
||||
{statusMeta.text}
|
||||
</Typography.Text>
|
||||
{segmentSummary && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
|
||||
已标注 {segmentSummary.done}/{segmentSummary.total}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
{item.annotationUpdatedAt && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
|
||||
{item.annotationUpdatedAt}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum AnnotationTaskStatus {
|
||||
|
||||
export enum AnnotationResultStatus {
|
||||
ANNOTATED = "ANNOTATED",
|
||||
IN_PROGRESS = "IN_PROGRESS",
|
||||
NO_ANNOTATION = "NO_ANNOTATION",
|
||||
NOT_APPLICABLE = "NOT_APPLICABLE",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user