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, listEditorTasksUsingGet,
upsertEditorAnnotationUsingPut, upsertEditorAnnotationUsingPut,
} from "../annotation.api"; } from "../annotation.api";
import { AnnotationResultStatus } from "../annotation.model";
type EditorProjectInfo = { type EditorProjectInfo = {
projectId: string; projectId: string;
@@ -26,6 +27,7 @@ type EditorTaskListItem = {
fileType?: string | null; fileType?: string | null;
hasAnnotation: boolean; hasAnnotation: boolean;
annotationUpdatedAt?: string | null; annotationUpdatedAt?: string | null;
annotationStatus?: AnnotationResultStatus | null;
}; };
type LsfMessage = { type LsfMessage = {
@@ -88,6 +90,12 @@ type SwitchDecision = "save" | "discard" | "cancel";
const LSF_IFRAME_SRC = "/lsf/lsf.html"; const LSF_IFRAME_SRC = "/lsf/lsf.html";
const TASK_PAGE_START = 0; const TASK_PAGE_START = 0;
const TASK_PAGE_SIZE = 200; 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 = { type NormalizedTaskList = {
items: EditorTaskListItem[]; items: EditorTaskListItem[];
@@ -119,6 +127,27 @@ const resolvePayloadMessage = (payload: unknown) => {
const isRecord = (value: unknown): value is Record<string, unknown> => const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value); !!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 => { const normalizeSnapshotValue = (value: unknown, seen: WeakSet<object>): unknown => {
if (!value || typeof value !== "object") return value; if (!value || typeof value !== "object") return value;
const obj = value as object; const obj = value as object;
@@ -247,6 +276,36 @@ export default function LabelStudioTextEditor() {
win.postMessage({ type, payload }, origin); win.postMessage({ type, payload }, origin);
}, [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 () => { const loadProject = useCallback(async () => {
setLoadingProject(true); setLoadingProject(true);
try { try {
@@ -539,11 +598,29 @@ export default function LabelStudioTextEditor() {
? currentSegmentIndex ? currentSegmentIndex
: undefined; : 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); setSaving(true);
try { try {
const resp = (await upsertEditorAnnotationUsingPut(projectId, String(fileId), { const resp = (await upsertEditorAnnotationUsingPut(projectId, String(fileId), {
annotation, annotation,
segmentIndex, segmentIndex,
annotationStatus: resolvedStatus,
})) as ApiResponse<UpsertAnnotationResponse>; })) as ApiResponse<UpsertAnnotationResponse>;
const updatedAt = resp?.data?.updatedAt; const updatedAt = resp?.data?.updatedAt;
message.success("标注已保存"); message.success("标注已保存");
@@ -553,6 +630,7 @@ export default function LabelStudioTextEditor() {
? { ? {
...item, ...item,
hasAnnotation: true, hasAnnotation: true,
annotationStatus: resolvedStatus,
annotationUpdatedAt: updatedAt || item.annotationUpdatedAt, annotationUpdatedAt: updatedAt || item.annotationUpdatedAt,
} }
: item : item
@@ -586,11 +664,13 @@ export default function LabelStudioTextEditor() {
} }
}, [ }, [
advanceAfterSave, advanceAfterSave,
confirmEmptyAnnotationStatus,
currentSegmentIndex, currentSegmentIndex,
message, message,
projectId, projectId,
segmented, segmented,
selectedFileId, selectedFileId,
tasks,
]); ]);
const requestExportForCheck = useCallback(() => { const requestExportForCheck = useCallback(() => {
@@ -1016,37 +1096,37 @@ export default function LabelStudioTextEditor() {
size="small" size="small"
dataSource={tasks} dataSource={tasks}
loadMore={loadMoreNode} loadMore={loadMoreNode}
renderItem={(item) => ( renderItem={(item) => {
<List.Item const statusMeta = resolveTaskStatusMeta(item);
key={item.fileId} return (
className="cursor-pointer hover:bg-blue-50" <List.Item
style={{ key={item.fileId}
background: item.fileId === selectedFileId ? "#e6f4ff" : undefined, className="cursor-pointer hover:bg-blue-50"
padding: "8px 12px", style={{
borderBottom: "1px solid #f0f0f0", background: item.fileId === selectedFileId ? "#e6f4ff" : undefined,
}} padding: "8px 12px",
onClick={() => setSelectedFileId(item.fileId)} borderBottom: "1px solid #f0f0f0",
> }}
<div className="flex flex-col w-full gap-1"> onClick={() => setSelectedFileId(item.fileId)}
<Typography.Text ellipsis style={{ fontSize: 13 }}> >
{item.fileName} <div className="flex flex-col w-full gap-1">
</Typography.Text> <Typography.Text ellipsis style={{ fontSize: 13 }}>
<div className="flex items-center justify-between"> {item.fileName}
<Typography.Text
type={item.hasAnnotation ? "success" : "secondary"}
style={{ fontSize: 11 }}
>
{item.hasAnnotation ? "已标注" : "未标注"}
</Typography.Text> </Typography.Text>
{item.annotationUpdatedAt && ( <div className="flex items-center justify-between">
<Typography.Text type="secondary" style={{ fontSize: 10 }}> <Typography.Text type={statusMeta.type} style={{ fontSize: 11 }}>
{item.annotationUpdatedAt} {statusMeta.text}
</Typography.Text> </Typography.Text>
)} {item.annotationUpdatedAt && (
<Typography.Text type="secondary" style={{ fontSize: 10 }}>
{item.annotationUpdatedAt}
</Typography.Text>
)}
</div>
</div> </div>
</div> </List.Item>
</List.Item> );
)} }}
/> />
</div> </div>
{segmented && ( {segmented && (

View File

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

View File

@@ -2,11 +2,20 @@
import uuid import uuid
from sqlalchemy import Column, String, Boolean, TIMESTAMP, Text, Integer, JSON, ForeignKey, UniqueConstraint, Index from sqlalchemy import Column, String, Boolean, TIMESTAMP, Text, Integer, JSON, ForeignKey, UniqueConstraint, Index
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.db.session import Base from app.db.session import Base
class AnnotationTemplate(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):
"""标注配置模板模型""" """标注配置模板模型"""
__tablename__ = "t_dm_annotation_templates" __tablename__ = "t_dm_annotation_templates"
@@ -84,12 +93,18 @@ class AnnotationResult(Base):
__tablename__ = "t_dm_annotation_results" __tablename__ = "t_dm_annotation_results"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="UUID") id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="UUID")
project_id = Column(String(36), nullable=False, comment="标注项目ID(t_dm_labeling_projects.id)") 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)") 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 = Column(JSON, nullable=False, comment="Label Studio annotation 原始JSON(单人单份最终结果)")
created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间") annotation_status = Column(
updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), comment="更新时间") 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="更新时间")
def __repr__(self): def __repr__(self):
return f"<AnnotationResult(id={self.id}, project_id={self.project_id}, file_id={self.file_id})>" return f"<AnnotationResult(id={self.id}, project_id={self.project_id}, file_id={self.file_id})>"

View File

@@ -9,10 +9,25 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, ConfigDict 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): class EditorProjectInfo(BaseModel):
"""编辑器项目元信息""" """编辑器项目元信息"""
@@ -40,8 +55,13 @@ class EditorTaskListItem(BaseModel):
file_type: Optional[str] = Field(None, alias="fileType", description="文件类型") file_type: Optional[str] = Field(None, alias="fileType", description="文件类型")
has_annotation: bool = Field(..., alias="hasAnnotation", description="是否已有最终标注") has_annotation: bool = Field(..., alias="hasAnnotation", description="是否已有最终标注")
annotation_updated_at: Optional[datetime] = Field(None, alias="annotationUpdatedAt", 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): class EditorTaskListResponse(BaseModel):
@@ -89,6 +109,11 @@ class UpsertAnnotationRequest(BaseModel):
"""保存/覆盖最终标注(Label Studio annotation 原始对象)""" """保存/覆盖最终标注(Label Studio annotation 原始对象)"""
annotation: Dict[str, Any] = Field(..., description="Label Studio annotation 对象(包含 result 等)") 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( expected_updated_at: Optional[datetime] = Field(
None, None,
alias="expectedUpdatedAt", alias="expectedUpdatedAt",
@@ -101,7 +126,7 @@ class UpsertAnnotationRequest(BaseModel):
description="段落索引(分段模式下必填)", description="段落索引(分段模式下必填)",
) )
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True, use_enum_values=True)
class UpsertAnnotationResponse(BaseModel): class UpsertAnnotationResponse(BaseModel):

View File

@@ -24,6 +24,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.logging import get_logger from app.core.logging import get_logger
from app.db.models import AnnotationResult, Dataset, DatasetFiles, LabelingProject, LabelingProjectFile 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.config import LabelStudioTagConfig
from app.module.annotation.schema.editor import ( from app.module.annotation.schema.editor import (
EditorProjectInfo, EditorProjectInfo,
@@ -348,6 +354,24 @@ class AnnotationEditorService:
return ET.tostring(root, encoding="unicode") 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 @classmethod
def _build_source_document_filter(cls): def _build_source_document_filter(cls):
file_type_lower = func.lower(DatasetFiles.file_type) file_type_lower = func.lower(DatasetFiles.file_type)
@@ -447,7 +471,12 @@ class AnnotationEditorService:
else_=0, else_=0,
) )
files_result = await self.db.execute( 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) .join(LabelingProjectFile, LabelingProjectFile.file_id == DatasetFiles.id)
.outerjoin( .outerjoin(
AnnotationResult, AnnotationResult,
@@ -462,7 +491,7 @@ class AnnotationEditorService:
rows = files_result.all() rows = files_result.all()
items: List[EditorTaskListItem] = [] 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] fid = str(file_record.id) # type: ignore[arg-type]
items.append( items.append(
EditorTaskListItem( EditorTaskListItem(
@@ -471,6 +500,7 @@ class AnnotationEditorService:
fileType=getattr(file_record, "file_type", None), fileType=getattr(file_record, "file_type", None),
hasAnnotation=annotation_id is not None, hasAnnotation=annotation_id is not None,
annotationUpdatedAt=annotation_updated_at, 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) annotation_payload["id"] = self._make_ls_annotation_id(project_id, file_id)
final_payload = annotation_payload 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 existing:
if request.expected_updated_at and existing.updated_at: if request.expected_updated_at and existing.updated_at:
if existing.updated_at != request.expected_updated_at.replace(tzinfo=None): if existing.updated_at != request.expected_updated_at.replace(tzinfo=None):
raise HTTPException(status_code=409, detail="标注已被更新,请刷新后重试") raise HTTPException(status_code=409, detail="标注已被更新,请刷新后重试")
existing.annotation = final_payload # type: ignore[assignment] existing.annotation = final_payload # type: ignore[assignment]
existing.annotation_status = final_status # type: ignore[assignment]
existing.updated_at = now # type: ignore[assignment] existing.updated_at = now # type: ignore[assignment]
await self.db.commit() await self.db.commit()
await self.db.refresh(existing) await self.db.refresh(existing)
@@ -892,6 +938,7 @@ class AnnotationEditorService:
project_id=project_id, project_id=project_id,
file_id=file_id, file_id=file_id,
annotation=final_payload, annotation=final_payload,
annotation_status=final_status,
created_at=now, created_at=now,
updated_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', project_id VARCHAR(36) NOT NULL COMMENT '标注项目ID',
file_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 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 '创建时间', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_project_file (project_id, file_id), UNIQUE KEY uk_project_file (project_id, file_id),