Compare commits

...

2 Commits

Author SHA1 Message Date
f2403f00ce feat(annotation): 添加不适用标注状态支持
- 在 AnnotationResultStatus 枚举中新增 NOT_APPLICABLE 状态
- 将无标注/不适用合并为两个独立的状态选项
- 更新前端标签显示逻辑以支持新的状态类型
- 修改确认对话框允许选择不适用状态
- 在后端数据库模型中添加 NOT_APPLICABLE 状态值
- 更新 API schema 描述以反映新的状态选项
- 调整标注状态判断和保存逻辑以处理三种状态
- 更新数据库表结构注释包含新状态类型
2026-01-31 13:28:08 +08:00
f4fc574687 feat(annotation): 添加标注状态管理功能
- 引入 AnnotationResultStatus 枚举类型区分已标注和无标注状态
- 在前端组件中实现空标注检测和确认对话框逻辑
- 添加数据库表字段 annotation_status 存储标注状态
- 扩展后端服务验证和处理标注状态逻辑
- 更新 API 接口支持标注状态参数传递
- 改进任务列表显示逻辑以反映不同标注状态
- 实现分段模式下的标注结果检查机制
2026-01-31 13:23:38 +08:00
6 changed files with 218 additions and 44 deletions

View File

@@ -9,6 +9,7 @@ import {
listEditorTasksUsingGet,
upsertEditorAnnotationUsingPut,
} from "../annotation.api";
import { AnnotationResultStatus } from "../annotation.model";
type EditorProjectInfo = {
projectId: string;
@@ -26,6 +27,7 @@ type EditorTaskListItem = {
fileType?: string | null;
hasAnnotation: boolean;
annotationUpdatedAt?: string | null;
annotationStatus?: AnnotationResultStatus | null;
};
type LsfMessage = {
@@ -88,6 +90,12 @@ type SwitchDecision = "save" | "discard" | "cancel";
const LSF_IFRAME_SRC = "/lsf/lsf.html";
const TASK_PAGE_START = 0;
const TASK_PAGE_SIZE = 200;
const NO_ANNOTATION_LABEL = "无标注";
const NOT_APPLICABLE_LABEL = "不适用";
const NO_ANNOTATION_CONFIRM_TITLE = "没有标注任何内容";
const NO_ANNOTATION_CONFIRM_OK_TEXT = "设为无标注并保存";
const NOT_APPLICABLE_CONFIRM_TEXT = "设为不适用并保存";
const NO_ANNOTATION_CONFIRM_CANCEL_TEXT = "继续标注";
type NormalizedTaskList = {
items: EditorTaskListItem[];
@@ -119,6 +127,27 @@ const resolvePayloadMessage = (payload: unknown) => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
const isAnnotationResultEmpty = (annotation?: Record<string, unknown>) => {
if (!annotation) return true;
if (!("result" in annotation)) return true;
const result = (annotation as { result?: unknown }).result;
if (!Array.isArray(result)) return false;
return result.length === 0;
};
const resolveTaskStatusMeta = (item: EditorTaskListItem) => {
if (!item.hasAnnotation) {
return { text: "未标注", type: "secondary" as const };
}
if (item.annotationStatus === AnnotationResultStatus.NO_ANNOTATION) {
return { text: NO_ANNOTATION_LABEL, type: "warning" as const };
}
if (item.annotationStatus === AnnotationResultStatus.NOT_APPLICABLE) {
return { text: NOT_APPLICABLE_LABEL, type: "warning" as const };
}
return { text: "已标注", type: "success" as const };
};
const normalizeSnapshotValue = (value: unknown, seen: WeakSet<object>): unknown => {
if (!value || typeof value !== "object") return value;
const obj = value as object;
@@ -247,6 +276,36 @@ export default function LabelStudioTextEditor() {
win.postMessage({ type, payload }, origin);
}, [origin]);
const confirmEmptyAnnotationStatus = useCallback(() => {
return new Promise<AnnotationResultStatus | null>((resolve) => {
let resolved = false;
let modalInstance: { destroy: () => void } | null = null;
const settle = (value: AnnotationResultStatus | null) => {
if (resolved) return;
resolved = true;
resolve(value);
if (modalInstance) modalInstance.destroy();
};
const handleNotApplicable = () => settle(AnnotationResultStatus.NOT_APPLICABLE);
modalInstance = modal.confirm({
title: NO_ANNOTATION_CONFIRM_TITLE,
content: (
<div className="flex flex-col gap-2">
<Typography.Text></Typography.Text>
<Typography.Text type="secondary"></Typography.Text>
<Button type="link" style={{ padding: 0, height: "auto" }} onClick={handleNotApplicable}>
{NOT_APPLICABLE_CONFIRM_TEXT}
</Button>
</div>
),
okText: NO_ANNOTATION_CONFIRM_OK_TEXT,
cancelText: NO_ANNOTATION_CONFIRM_CANCEL_TEXT,
onOk: () => settle(AnnotationResultStatus.NO_ANNOTATION),
onCancel: () => settle(null),
});
});
}, [modal]);
const loadProject = useCallback(async () => {
setLoadingProject(true);
try {
@@ -539,11 +598,29 @@ export default function LabelStudioTextEditor() {
? currentSegmentIndex
: undefined;
const annotationRecord = annotation as Record<string, unknown>;
const currentTask = tasks.find((item) => item.fileId === String(fileId));
const currentStatus = currentTask?.annotationStatus;
const hasExistingAnnotation = !!currentTask?.hasAnnotation;
let resolvedStatus: AnnotationResultStatus;
if (isAnnotationResultEmpty(annotationRecord)) {
if (currentStatus === AnnotationResultStatus.ANNOTATED || (hasExistingAnnotation && !currentStatus)) {
resolvedStatus = AnnotationResultStatus.ANNOTATED;
} else {
const selectedStatus = await confirmEmptyAnnotationStatus();
if (!selectedStatus) return false;
resolvedStatus = selectedStatus;
}
} else {
resolvedStatus = AnnotationResultStatus.ANNOTATED;
}
setSaving(true);
try {
const resp = (await upsertEditorAnnotationUsingPut(projectId, String(fileId), {
annotation,
segmentIndex,
annotationStatus: resolvedStatus,
})) as ApiResponse<UpsertAnnotationResponse>;
const updatedAt = resp?.data?.updatedAt;
message.success("标注已保存");
@@ -553,6 +630,7 @@ export default function LabelStudioTextEditor() {
? {
...item,
hasAnnotation: true,
annotationStatus: resolvedStatus,
annotationUpdatedAt: updatedAt || item.annotationUpdatedAt,
}
: item
@@ -586,11 +664,13 @@ export default function LabelStudioTextEditor() {
}
}, [
advanceAfterSave,
confirmEmptyAnnotationStatus,
currentSegmentIndex,
message,
projectId,
segmented,
selectedFileId,
tasks,
]);
const requestExportForCheck = useCallback(() => {
@@ -1016,7 +1096,9 @@ export default function LabelStudioTextEditor() {
size="small"
dataSource={tasks}
loadMore={loadMoreNode}
renderItem={(item) => (
renderItem={(item) => {
const statusMeta = resolveTaskStatusMeta(item);
return (
<List.Item
key={item.fileId}
className="cursor-pointer hover:bg-blue-50"
@@ -1032,11 +1114,8 @@ export default function LabelStudioTextEditor() {
{item.fileName}
</Typography.Text>
<div className="flex items-center justify-between">
<Typography.Text
type={item.hasAnnotation ? "success" : "secondary"}
style={{ fontSize: 11 }}
>
{item.hasAnnotation ? "已标注" : "未标注"}
<Typography.Text type={statusMeta.type} style={{ fontSize: 11 }}>
{statusMeta.text}
</Typography.Text>
{item.annotationUpdatedAt && (
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
@@ -1046,7 +1125,8 @@ export default function LabelStudioTextEditor() {
</div>
</div>
</List.Item>
)}
);
}}
/>
</div>
{segmented && (

View File

@@ -8,6 +8,12 @@ export enum AnnotationTaskStatus {
SKIPPED = "skipped",
}
export enum AnnotationResultStatus {
ANNOTATED = "ANNOTATED",
NO_ANNOTATION = "NO_ANNOTATION",
NOT_APPLICABLE = "NOT_APPLICABLE",
}
export interface AnnotationTask {
id: string;
name: string;
@@ -52,7 +58,7 @@ export interface ObjectDefinition {
export interface TemplateConfiguration {
labels: LabelDefinition[];
objects: ObjectDefinition[];
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
}
export interface AnnotationTemplate {

View File

@@ -6,6 +6,15 @@ from sqlalchemy.sql import func
from app.db.session import Base
ANNOTATION_STATUS_ANNOTATED = "ANNOTATED"
ANNOTATION_STATUS_NO_ANNOTATION = "NO_ANNOTATION"
ANNOTATION_STATUS_NOT_APPLICABLE = "NOT_APPLICABLE"
ANNOTATION_STATUS_VALUES = {
ANNOTATION_STATUS_ANNOTATED,
ANNOTATION_STATUS_NO_ANNOTATION,
ANNOTATION_STATUS_NOT_APPLICABLE,
}
class AnnotationTemplate(Base):
"""标注配置模板模型"""
@@ -88,6 +97,12 @@ class AnnotationResult(Base):
project_id = Column(String(36), nullable=False, comment="标注项目ID(t_dm_labeling_projects.id)")
file_id = Column(String(36), nullable=False, comment="文件ID(t_dm_dataset_files.id)")
annotation = Column(JSON, nullable=False, comment="Label Studio annotation 原始JSON(单人单份最终结果)")
annotation_status = Column(
String(32),
nullable=False,
default=ANNOTATION_STATUS_ANNOTATED,
comment="标注状态: ANNOTATED/NO_ANNOTATION/NOT_APPLICABLE",
)
created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), comment="更新时间")

View File

@@ -9,10 +9,25 @@
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, ConfigDict
from app.db.models.annotation_management import (
ANNOTATION_STATUS_ANNOTATED,
ANNOTATION_STATUS_NO_ANNOTATION,
ANNOTATION_STATUS_NOT_APPLICABLE,
)
class AnnotationStatus(str, Enum):
"""标注状态枚举"""
ANNOTATED = ANNOTATION_STATUS_ANNOTATED
NO_ANNOTATION = ANNOTATION_STATUS_NO_ANNOTATION
NOT_APPLICABLE = ANNOTATION_STATUS_NOT_APPLICABLE
class EditorProjectInfo(BaseModel):
"""编辑器项目元信息"""
@@ -40,8 +55,13 @@ class EditorTaskListItem(BaseModel):
file_type: Optional[str] = Field(None, alias="fileType", description="文件类型")
has_annotation: bool = Field(..., alias="hasAnnotation", description="是否已有最终标注")
annotation_updated_at: Optional[datetime] = Field(None, alias="annotationUpdatedAt", description="标注更新时间")
annotation_status: Optional[AnnotationStatus] = Field(
None,
alias="annotationStatus",
description="标注状态",
)
model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True, use_enum_values=True)
class EditorTaskListResponse(BaseModel):
@@ -89,6 +109,11 @@ class UpsertAnnotationRequest(BaseModel):
"""保存/覆盖最终标注(Label Studio annotation 原始对象)"""
annotation: Dict[str, Any] = Field(..., description="Label Studio annotation 对象(包含 result 等)")
annotation_status: Optional[AnnotationStatus] = Field(
None,
alias="annotationStatus",
description="标注状态(无标注传 NO_ANNOTATION,不适用传 NOT_APPLICABLE)",
)
expected_updated_at: Optional[datetime] = Field(
None,
alias="expectedUpdatedAt",
@@ -101,7 +126,7 @@ class UpsertAnnotationRequest(BaseModel):
description="段落索引(分段模式下必填)",
)
model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True, use_enum_values=True)
class UpsertAnnotationResponse(BaseModel):

View File

@@ -24,6 +24,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.logging import get_logger
from app.db.models import AnnotationResult, Dataset, DatasetFiles, LabelingProject, LabelingProjectFile
from app.db.models.annotation_management import (
ANNOTATION_STATUS_ANNOTATED,
ANNOTATION_STATUS_NO_ANNOTATION,
ANNOTATION_STATUS_NOT_APPLICABLE,
ANNOTATION_STATUS_VALUES,
)
from app.module.annotation.config import LabelStudioTagConfig
from app.module.annotation.schema.editor import (
EditorProjectInfo,
@@ -348,6 +354,24 @@ class AnnotationEditorService:
return ET.tostring(root, encoding="unicode")
@staticmethod
def _has_annotation_result(payload: Optional[Dict[str, Any]]) -> bool:
if not payload or not isinstance(payload, dict):
return False
if payload.get("segmented"):
segments = payload.get("segments", {})
if not isinstance(segments, dict):
return False
for segment in segments.values():
if not isinstance(segment, dict):
continue
result = segment.get("result")
if isinstance(result, list) and len(result) > 0:
return True
return False
result = payload.get("result")
return isinstance(result, list) and len(result) > 0
@classmethod
def _build_source_document_filter(cls):
file_type_lower = func.lower(DatasetFiles.file_type)
@@ -447,7 +471,12 @@ class AnnotationEditorService:
else_=0,
)
files_result = await self.db.execute(
select(DatasetFiles, AnnotationResult.id, AnnotationResult.updated_at)
select(
DatasetFiles,
AnnotationResult.id,
AnnotationResult.updated_at,
AnnotationResult.annotation_status,
)
.join(LabelingProjectFile, LabelingProjectFile.file_id == DatasetFiles.id)
.outerjoin(
AnnotationResult,
@@ -462,7 +491,7 @@ class AnnotationEditorService:
rows = files_result.all()
items: List[EditorTaskListItem] = []
for file_record, annotation_id, annotation_updated_at in rows:
for file_record, annotation_id, annotation_updated_at, annotation_status in rows:
fid = str(file_record.id) # type: ignore[arg-type]
items.append(
EditorTaskListItem(
@@ -471,6 +500,7 @@ class AnnotationEditorService:
fileType=getattr(file_record, "file_type", None),
hasAnnotation=annotation_id is not None,
annotationUpdatedAt=annotation_updated_at,
annotationStatus=annotation_status,
)
)
@@ -869,12 +899,28 @@ class AnnotationEditorService:
annotation_payload["id"] = self._make_ls_annotation_id(project_id, file_id)
final_payload = annotation_payload
requested_status = request.annotation_status
if requested_status is not None and requested_status not in ANNOTATION_STATUS_VALUES:
raise HTTPException(status_code=400, detail="annotationStatus 不合法")
has_result = self._has_annotation_result(final_payload)
if has_result:
final_status = ANNOTATION_STATUS_ANNOTATED
else:
if requested_status == ANNOTATION_STATUS_NO_ANNOTATION:
final_status = ANNOTATION_STATUS_NO_ANNOTATION
elif requested_status == ANNOTATION_STATUS_NOT_APPLICABLE:
final_status = ANNOTATION_STATUS_NOT_APPLICABLE
else:
raise HTTPException(status_code=400, detail="未发现标注内容,请确认无标注/不适用后再保存")
if existing:
if request.expected_updated_at and existing.updated_at:
if existing.updated_at != request.expected_updated_at.replace(tzinfo=None):
raise HTTPException(status_code=409, detail="标注已被更新,请刷新后重试")
existing.annotation = final_payload # type: ignore[assignment]
existing.annotation_status = final_status # type: ignore[assignment]
existing.updated_at = now # type: ignore[assignment]
await self.db.commit()
await self.db.refresh(existing)
@@ -892,6 +938,7 @@ class AnnotationEditorService:
project_id=project_id,
file_id=file_id,
annotation=final_payload,
annotation_status=final_status,
created_at=now,
updated_at=now,
)

View File

@@ -65,6 +65,7 @@ CREATE TABLE IF NOT EXISTS t_dm_annotation_results (
project_id VARCHAR(36) NOT NULL COMMENT '标注项目ID',
file_id VARCHAR(36) NOT NULL COMMENT '文件ID',
annotation JSON NOT NULL COMMENT 'Label Studio annotation 原始JSON',
annotation_status VARCHAR(32) NOT NULL DEFAULT 'ANNOTATED' COMMENT '标注状态: ANNOTATED/NO_ANNOTATION/NOT_APPLICABLE',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_project_file (project_id, file_id),