feat: 增加标签黑名单功能

This commit is contained in:
binfeng
2026-08-28 16:13:32 +08:00
parent 1719cbe804
commit 2ca9d12fa3
36 changed files with 1412 additions and 27 deletions
+17
View File
@@ -81,3 +81,20 @@ class ReviewTagSimilarAckRequiredError(BaseErrorCode):
Code: int = 10714
Msg: str = "Similar tags exist in the target library; confirmation is required"
class TagBlacklistLimitExceededError(BaseErrorCode):
"""Inserting the rejected names would push the tenant blacklist past 1000 rows."""
Code: int = 10715
Msg: str = "Tag blacklist would exceed the 1000-item limit"
class TagBlacklistNotFoundError(BaseErrorCode):
Code: int = 10716
Msg: str = "Tag blacklist entry not found"
class TagBlacklistAlreadyExistError(BaseErrorCode):
Code: int = 10717
Msg: str = "Tag is already in the blacklist"
@@ -0,0 +1,79 @@
"""F101: tenant tag blacklist for rejected review tags.
Revision ID: f101_tag_blacklist
Revises: f100_migration_preserve_link
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
from bisheng.core.database.dialect_helpers import UPDATE_TIME_SERVER_DEFAULT, table_exists
revision: str = "f101_tag_blacklist"
down_revision: Union[str, Sequence[str], None] = "f100_migration_preserve_link"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_TABLE = "tag_blacklist"
def _create_table() -> None:
op.create_table(
_TABLE,
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"tenant_id",
sa.Integer(),
nullable=False,
server_default=sa.text("1"),
comment="Tenant ID",
),
sa.Column("name", sa.String(length=255), nullable=False, comment="Blacklisted tag name"),
sa.Column(
"name_key",
sa.String(length=255),
nullable=False,
comment="Normalized unique name key",
),
sa.Column(
"user_id",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
comment="User who added the row",
),
sa.Column(
"create_time",
sa.DateTime(),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"update_time",
sa.DateTime(),
nullable=False,
server_default=UPDATE_TIME_SERVER_DEFAULT,
),
sa.UniqueConstraint("tenant_id", "name_key", name="uq_tag_blacklist_tenant_name_key"),
)
op.create_index("ix_tag_blacklist_tenant_id", _TABLE, ["tenant_id"])
def upgrade() -> None:
conn = op.get_bind()
if table_exists(conn, _TABLE):
return
_create_table()
def downgrade() -> None:
conn = op.get_bind()
if not table_exists(conn, _TABLE):
return
op.drop_index("ix_tag_blacklist_tenant_id", table_name=_TABLE)
op.drop_table(_TABLE)
@@ -83,6 +83,7 @@ _TENANT_AWARE_MODEL_MODULES = (
"bisheng.knowledge.domain.models.knowledge_file",
"bisheng.knowledge.domain.models.knowledge_file_pdf_artifact",
"bisheng.knowledge.domain.models.knowledge_file_similarity_candidate",
"bisheng.knowledge.domain.models.tag_blacklist",
"bisheng.knowledge.domain.models.knowledge_fulltext_outbox",
"bisheng.knowledge.domain.models.portal_recommendation_file_projection",
"bisheng.knowledge.domain.models.portal_hot_search_snapshot",
@@ -0,0 +1,150 @@
"""Rejected tag names that must not be generated or re-proposed."""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Column, DateTime, Integer, String, UniqueConstraint, delete, func, text
from sqlalchemy.exc import IntegrityError
from sqlmodel import Field, col, select
from bisheng.common.models.base import SQLModelSerializable
from bisheng.core.database import get_async_db_session, get_sync_db_session
from bisheng.core.database.dialect_helpers import UPDATE_TIME_SERVER_DEFAULT
class TagBlacklistBase(SQLModelSerializable):
tenant_id: int | None = Field(
default=None,
sa_column=Column(
Integer,
nullable=False,
server_default=text("1"),
index=True,
comment="Tenant ID",
),
)
name: str = Field(
sa_column=Column(String(255), nullable=False, comment="Blacklisted tag name"),
)
name_key: str = Field(
sa_column=Column(String(255), nullable=False, comment="Normalized unique name key"),
)
user_id: int = Field(
default=0,
sa_column=Column(Integer, nullable=False, server_default=text("0"), comment="User who added the row"),
)
create_time: datetime | None = Field(
default=None,
sa_column=Column(DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP")),
)
update_time: datetime | None = Field(
default=None,
sa_column=Column(DateTime, nullable=False, server_default=UPDATE_TIME_SERVER_DEFAULT),
)
class TagBlacklist(TagBlacklistBase, table=True):
__tablename__ = "tag_blacklist"
__table_args__ = (UniqueConstraint("tenant_id", "name_key", name="uq_tag_blacklist_tenant_name_key"),)
id: int | None = Field(default=None, primary_key=True)
class TagBlacklistDao:
@classmethod
def count_sync(cls) -> int:
statement = select(func.count(TagBlacklist.id))
with get_sync_db_session() as session:
return session.scalar(statement) or 0
@classmethod
async def acount(cls) -> int:
statement = select(func.count(TagBlacklist.id))
async with get_async_db_session() as session:
return await session.scalar(statement) or 0
@classmethod
def list_catalog_entries_sync(cls) -> list[tuple[str, str]]:
statement = select(TagBlacklist.name, TagBlacklist.name_key)
with get_sync_db_session() as session:
rows = session.exec(statement).all()
return [(str(name).strip(), str(key)) for name, key in rows if str(name or "").strip() and key]
@classmethod
async def alist_existing_name_keys(cls, name_keys: list[str]) -> set[str]:
if not name_keys:
return set()
statement = select(TagBlacklist.name_key).where(col(TagBlacklist.name_key).in_(name_keys))
async with get_async_db_session() as session:
rows = (await session.exec(statement)).all()
return {str(key) for key in rows if key}
@classmethod
def list_existing_name_keys_sync(cls, name_keys: list[str]) -> set[str]:
if not name_keys:
return set()
statement = select(TagBlacklist.name_key).where(col(TagBlacklist.name_key).in_(name_keys))
with get_sync_db_session() as session:
rows = session.exec(statement).all()
return {str(key) for key in rows if key}
@classmethod
async def asearch(
cls,
*,
keyword: str | None,
offset: int,
limit: int,
) -> tuple[list[TagBlacklist], int]:
statement = select(TagBlacklist)
count_statement = select(func.count(TagBlacklist.id))
trimmed = (keyword or "").strip()
if trimmed:
like = f"%{trimmed}%"
statement = statement.where(TagBlacklist.name.like(like))
count_statement = count_statement.where(TagBlacklist.name.like(like))
statement = statement.order_by(col(TagBlacklist.id).desc()).offset(offset).limit(limit)
async with get_async_db_session() as session:
rows = list((await session.exec(statement)).all())
total = await session.scalar(count_statement) or 0
return rows, int(total)
@classmethod
async def aget(cls, blacklist_id: int) -> TagBlacklist | None:
async with get_async_db_session() as session:
return await session.get(TagBlacklist, blacklist_id)
@classmethod
async def aadd(cls, *, name: str, name_key: str, user_id: int) -> TagBlacklist | None:
row = TagBlacklist(name=name, name_key=name_key, user_id=user_id)
async with get_async_db_session() as session:
session.add(row)
try:
await session.commit()
except IntegrityError:
await session.rollback()
return None
await session.refresh(row)
return row
@classmethod
def add_sync(cls, *, name: str, name_key: str, user_id: int) -> TagBlacklist | None:
row = TagBlacklist(name=name, name_key=name_key, user_id=user_id)
with get_sync_db_session() as session:
session.add(row)
try:
session.commit()
except IntegrityError:
session.rollback()
return None
session.refresh(row)
return row
@classmethod
async def adelete(cls, blacklist_id: int) -> bool:
statement = delete(TagBlacklist).where(TagBlacklist.id == blacklist_id)
async with get_async_db_session() as session:
result = await session.exec(statement)
await session.commit()
return bool(getattr(result, "rowcount", 0))
@@ -32,6 +32,7 @@ from bisheng.knowledge.domain.models.knowledge_space_tag_library import (
from bisheng.knowledge.domain.models.knowledge_tag_library_link import (
KnowledgeTagLibraryLinkDao,
)
from bisheng.knowledge.domain.services.tag_blacklist_service import TagBlacklistService
from bisheng.knowledge.domain.services.tag_library_tag_service import (
TagLibraryTagService,
)
@@ -186,6 +187,7 @@ class KnowledgeSpaceAutoTagService:
excluded = {name.strip() for name in (exclude_names or []) if str(name).strip()}
manual_tags, ai_tags = cls._collect_library_tags(bound_ids)
candidates = [tag for tag in dict.fromkeys(manual_tags + ai_tags) if tag and tag not in excluded]
candidates = cls._exclude_blacklisted(candidates)
if not candidates:
return []
if len(candidates) <= limit:
@@ -349,6 +351,9 @@ class KnowledgeSpaceAutoTagService:
return []
excluded = {name.strip() for name in exclude_names if str(name).strip()}
manual_tags, ai_tags = cls._collect_library_tags(library_ids)
catalog = cls._blacklist_catalog()
manual_tags = TagBlacklistService.filter_unblocked_names(manual_tags, catalog)
ai_tags = TagBlacklistService.filter_unblocked_names(ai_tags, catalog)
manual_tags = [tag for tag in manual_tags if tag not in excluded]
ai_tags = [tag for tag in ai_tags if tag not in excluded]
if not manual_tags and not ai_tags:
@@ -384,6 +389,14 @@ class KnowledgeSpaceAutoTagService:
applied.extend(ai_matched)
return applied
@classmethod
def _blacklist_catalog(cls) -> list[tuple[str, str]]:
return TagBlacklistService.list_catalog_entries_sync()
@classmethod
def _exclude_blacklisted(cls, names: Sequence[str]) -> list[str]:
return TagBlacklistService.filter_unblocked_names(names, cls._blacklist_catalog())
@classmethod
def _resolve_library_ids(cls, knowledge: Knowledge) -> list[int]:
return KnowledgeTagLibraryLinkDao.list_library_ids_by_knowledge(int(knowledge.id))
@@ -19,6 +19,7 @@ from bisheng.knowledge.domain.services.knowledge_space_auto_tag_service import (
AUTO_TAG_MAX_AI_TAGS_PER_FILE,
KnowledgeSpaceAutoTagService,
)
from bisheng.knowledge.domain.services.tag_blacklist_service import TagBlacklistService
from bisheng.knowledge.domain.services.tag_library_tag_service import (
LINK_B_PROMPT_CATALOG_LIMIT,
TagLibraryTagService,
@@ -137,6 +138,8 @@ class KnowledgeSpaceReviewTagService:
library_ids = KnowledgeSpaceAutoTagService._resolve_library_ids(knowledge)
manual_tags, ai_tags = KnowledgeSpaceAutoTagService._collect_library_tags(library_ids)
tags_list = list(dict.fromkeys(tag for tag in manual_tags + ai_tags if tag))
blacklist_catalog = KnowledgeSpaceAutoTagService._blacklist_catalog()
tags_list = TagBlacklistService.filter_unblocked_names(tags_list, blacklist_catalog)
catalog = TagLibraryTagService.load_link_b_tenant_catalog_sync(
db_file.tenant_id,
@@ -148,6 +151,12 @@ class KnowledgeSpaceReviewTagService:
tenant_pending_names = [(tag.name or "").strip() for tag in pending_catalog if (tag.name or "").strip()][
:LINK_B_PROMPT_CATALOG_LIMIT
]
tenant_library_names = TagBlacklistService.filter_unblocked_names(
tenant_library_names, blacklist_catalog
)
tenant_pending_names = TagBlacklistService.filter_unblocked_names(
tenant_pending_names, blacklist_catalog
)
selected = cls._invoke_llm(
llm,
@@ -157,6 +166,7 @@ class KnowledgeSpaceReviewTagService:
tenant_library_names=tenant_library_names,
tenant_pending_names=tenant_pending_names,
)
selected = TagBlacklistService.filter_unblocked_names(selected, blacklist_catalog)
if not selected:
logger.info(
"review_tag_no_llm_output space_id={} file_id={}",
@@ -0,0 +1,186 @@
"""Tenant tag blacklist: reject auto-insert, search, and AI-candidate filtering."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from bisheng.common.errcode.tag import (
TagBlacklistAlreadyExistError,
TagBlacklistLimitExceededError,
TagBlacklistNotFoundError,
TagNameParamsIsEmptyError,
)
from bisheng.knowledge.domain.models.tag_blacklist import TagBlacklist, TagBlacklistDao
from bisheng.knowledge.domain.services.tag_library_tag_service import (
PENDING_REVIEW_TAG_SIMILARITY_THRESHOLD,
TagLibraryTagService,
)
TAG_BLACKLIST_MAX = 1000
@dataclass(frozen=True)
class TagBlacklistPreview:
count: int
limit: int
new_count: int
would_exceed: bool
class TagBlacklistService:
@staticmethod
def normalize_name_key(name: str) -> str:
return TagLibraryTagService.normalize_tag_name_key(name)
@classmethod
def _unique_names(cls, names: Sequence[str]) -> list[tuple[str, str]]:
unique: list[tuple[str, str]] = []
seen: set[str] = set()
for raw in names:
name = str(raw or "").strip()
key = cls.normalize_name_key(name)
if not key or key in seen:
continue
seen.add(key)
unique.append((name, key))
return unique
@classmethod
async def preview_insert_async(cls, names: Sequence[str]) -> TagBlacklistPreview:
unique = cls._unique_names(names)
count = await TagBlacklistDao.acount()
existing = await TagBlacklistDao.alist_existing_name_keys([key for _, key in unique])
new_count = sum(1 for _, key in unique if key not in existing)
return TagBlacklistPreview(
count=count,
limit=TAG_BLACKLIST_MAX,
new_count=new_count,
would_exceed=count + new_count > TAG_BLACKLIST_MAX,
)
@classmethod
def preview_insert_sync(cls, names: Sequence[str]) -> TagBlacklistPreview:
unique = cls._unique_names(names)
count = TagBlacklistDao.count_sync()
existing = TagBlacklistDao.list_existing_name_keys_sync([key for _, key in unique])
new_count = sum(1 for _, key in unique if key not in existing)
return TagBlacklistPreview(
count=count,
limit=TAG_BLACKLIST_MAX,
new_count=new_count,
would_exceed=count + new_count > TAG_BLACKLIST_MAX,
)
@classmethod
async def ensure_can_insert_async(cls, names: Sequence[str]) -> TagBlacklistPreview:
preview = await cls.preview_insert_async(names)
if preview.would_exceed:
raise TagBlacklistLimitExceededError(
count=preview.count,
limit=preview.limit,
new_count=preview.new_count,
)
return preview
@classmethod
async def add_names_async(cls, names: Sequence[str], user_id: int) -> int:
unique = cls._unique_names(names)
if not unique:
return 0
existing = await TagBlacklistDao.alist_existing_name_keys([key for _, key in unique])
remaining = max(0, TAG_BLACKLIST_MAX - await TagBlacklistDao.acount())
inserted = 0
for name, key in unique:
if key in existing:
continue
if inserted >= remaining:
break
row = await TagBlacklistDao.aadd(name=name, name_key=key, user_id=user_id)
if row is not None:
inserted += 1
return inserted
@classmethod
async def add_name_async(cls, name: str, user_id: int) -> TagBlacklist:
unique = cls._unique_names([name])
if not unique:
raise TagNameParamsIsEmptyError()
display, key = unique[0]
existing = await TagBlacklistDao.alist_existing_name_keys([key])
if key in existing:
raise TagBlacklistAlreadyExistError()
await cls.ensure_can_insert_async([display])
row = await TagBlacklistDao.aadd(name=display, name_key=key, user_id=user_id)
if row is None:
raise TagBlacklistAlreadyExistError()
return row
@classmethod
def list_catalog_entries_sync(cls) -> list[tuple[str, str]]:
return TagBlacklistDao.list_catalog_entries_sync()
@classmethod
def is_blocked_name(
cls,
name: str,
catalog: Sequence[tuple[str, str]] | None = None,
*,
similarity_threshold: float = PENDING_REVIEW_TAG_SIMILARITY_THRESHOLD,
) -> bool:
entries = list(catalog) if catalog is not None else cls.list_catalog_entries_sync()
if not entries:
return False
_, match_kind, _ = TagLibraryTagService.find_similar_tag_name(
name,
entries,
similarity_threshold=similarity_threshold,
allow_substring=True,
)
return match_kind != "new"
@classmethod
def filter_unblocked_names(
cls,
names: Sequence[str],
catalog: Sequence[tuple[str, str]] | None = None,
*,
similarity_threshold: float = PENDING_REVIEW_TAG_SIMILARITY_THRESHOLD,
) -> list[str]:
entries = list(catalog) if catalog is not None else cls.list_catalog_entries_sync()
if not entries:
return [str(name).strip() for name in names if str(name or "").strip()]
kept: list[str] = []
seen: set[str] = set()
for raw in names:
name = str(raw or "").strip()
if not name:
continue
key = cls.normalize_name_key(name)
if key in seen:
continue
if cls.is_blocked_name(name, entries, similarity_threshold=similarity_threshold):
continue
seen.add(key)
kept.append(name)
return kept
@classmethod
async def search_async(
cls,
*,
keyword: str | None,
page: int,
page_size: int,
) -> tuple[list[TagBlacklist], int, int]:
offset = (page - 1) * page_size
rows, total = await TagBlacklistDao.asearch(keyword=keyword, offset=offset, limit=page_size)
count = await TagBlacklistDao.acount()
return rows, total, count
@classmethod
async def delete_async(cls, blacklist_id: int) -> None:
row = await TagBlacklistDao.aget(blacklist_id)
if row is None:
raise TagBlacklistNotFoundError()
await TagBlacklistDao.adelete(blacklist_id)
@@ -12,6 +12,7 @@ from bisheng.workstation.domain.schemas.review_tags_schema import (
ReviewTagSimilarBatchCheckRequest,
ReviewTagSimilarCheckRequest,
)
from bisheng.workstation.domain.schemas.tag_console_schema import TagConsoleBlacklistPreviewReq
from bisheng.workstation.domain.services.workstation_tags_service import WorkStationTagsService
from ..dependencies import LoginUserDep
@@ -97,6 +98,14 @@ async def approve_or_reject_review_tags(
return resp_200(existed_tag_list)
@router.post("/blacklist/preview", summary="Preview tag blacklist insert capacity", response_model=UnifiedResponseModel)
async def preview_tag_blacklist(
data: TagConsoleBlacklistPreviewReq = Body(...),
tags_service: WorkStationTagsService = Depends(get_workstation_tags_service),
):
return resp_200(await tags_service.preview_tag_blacklist(data.names))
# 删除-待审核标签
@router.post("/delete_review", summary="Delete review tag", response_model=UnifiedResponseModel)
async def delete_review_tags(
@@ -4,7 +4,7 @@ Two modes, two independent listings: ``/search`` reads approved tags, and
``/review/*`` reads and acts on pending / rejected ones.
"""
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Path, Query
from bisheng.api.v1.schemas import UnifiedResponseModel, resp_200
from bisheng.workstation.api.dependencies import get_tag_console_service
@@ -13,6 +13,9 @@ from bisheng.workstation.domain.schemas.tag_console_schema import (
TagConsoleBatchDeleteReq,
TagConsoleBatchMoveReq,
TagConsoleBatchRejectReq,
TagConsoleBlacklistCreateReq,
TagConsoleBlacklistPreviewReq,
TagConsoleBlacklistSearchReq,
TagConsoleCreateReq,
TagConsoleReviewRef,
TagConsoleReviewSearchReq,
@@ -94,7 +97,12 @@ async def batch_reject(
service: TagConsoleService = Depends(get_tag_console_service),
login_user=LoginUserDep,
):
return resp_200(await service.batch_reject(req.items, req.reject_reason, login_user.tenant_id))
return resp_200(await service.batch_reject(
req.items,
req.reject_reason,
login_user.tenant_id,
skip_blacklist=req.skip_blacklist,
))
@router.get("/review/pending-count", summary="Badge count for the pending entry", response_model=UnifiedResponseModel)
@@ -114,3 +122,41 @@ async def list_source_knowledges(
login_user=LoginUserDep,
):
return resp_200(await service.list_source_knowledges(login_user.tenant_id, keyword))
@router.get("/blacklist", summary="List tag blacklist", response_model=UnifiedResponseModel)
async def list_blacklist(
keyword: str | None = Query(default=None),
page: int = Query(default=1),
page_size: int = Query(default=20),
service: TagConsoleService = Depends(get_tag_console_service),
):
return resp_200(
await service.list_blacklist(
TagConsoleBlacklistSearchReq(keyword=keyword, page=page, page_size=page_size)
)
)
@router.post("/blacklist/preview", summary="Preview blacklist insert capacity", response_model=UnifiedResponseModel)
async def preview_blacklist(
req: TagConsoleBlacklistPreviewReq,
service: TagConsoleService = Depends(get_tag_console_service),
):
return resp_200(await service.preview_blacklist(req.names))
@router.post("/blacklist", summary="Add a tag blacklist entry", response_model=UnifiedResponseModel)
async def add_blacklist(
req: TagConsoleBlacklistCreateReq,
service: TagConsoleService = Depends(get_tag_console_service),
):
return resp_200(await service.add_blacklist(req))
@router.delete("/blacklist/{blacklist_id}", summary="Remove a blacklist entry", response_model=UnifiedResponseModel)
async def delete_blacklist(
blacklist_id: int = Path(..., ge=1),
service: TagConsoleService = Depends(get_tag_console_service),
):
return resp_200(await service.delete_blacklist(blacklist_id))
@@ -78,6 +78,7 @@ class ApproveOrRejectRequest(BaseModel):
tag_library_id: int | None = None
knowledge_id: int | None = None
ack_similar: bool = False
skip_blacklist: bool = False
class ReviewTagSimilarCheckRequest(BaseModel):
@@ -209,6 +209,42 @@ class TagConsoleBatchApproveReq(BaseModel):
class TagConsoleBatchRejectReq(BaseModel):
items: list[TagConsoleReviewRef]
reject_reason: str
skip_blacklist: bool = False
class TagConsoleBlacklistSearchReq(BaseModel):
keyword: str | None = None
page: int = 1
page_size: int = 20
class TagConsoleBlacklistItem(BaseModel):
id: int
name: str
user_id: int = 0
create_time: datetime | None = None
class TagConsoleBlacklistSearchResp(BaseModel):
data: list[TagConsoleBlacklistItem]
total: int
count: int
limit: int = 1000
class TagConsoleBlacklistPreviewReq(BaseModel):
names: list[str] = Field(default_factory=list)
class TagConsoleBlacklistCreateReq(BaseModel):
name: str
class TagConsoleBlacklistPreviewResp(BaseModel):
count: int
limit: int = 1000
new_count: int
would_exceed: bool
# --------------------------------------------------------------------------
@@ -12,7 +12,6 @@ from bisheng.common.errcode.knowledge import (
)
from bisheng.common.errcode.tag import ReviewTagNotFoundError
from bisheng.common.errcode.workstation import (
TagConsoleActionNotApplicableError,
TagConsoleBatchTooLargeError,
TagConsolePageParamsError,
TagConsoleRejectReasonRequiredError,
@@ -22,9 +21,11 @@ from bisheng.database.models.tag import Tag, TagBusinessTypeEnum, TagResourceTyp
from bisheng.knowledge.domain.services.knowledge_space_tag_library_service import (
KnowledgeSpaceTagLibraryService,
)
from bisheng.knowledge.domain.services.tag_blacklist_service import TAG_BLACKLIST_MAX, TagBlacklistService
from bisheng.knowledge.domain.services.tag_library_tag_service import TagLibraryTagService
from bisheng.user.domain.models.user import UserDao
from bisheng.workstation.domain.repositories.tag_console_repository import (
PENDING_STATUS,
REJECTED_STATUS,
SOURCE_REVIEW,
SOURCE_TAG,
@@ -36,6 +37,10 @@ from bisheng.workstation.domain.schemas.tag_console_schema import (
MAX_PAGE_SIZE,
TagConsoleBatchFailure,
TagConsoleBatchResult,
TagConsoleBlacklistCreateReq,
TagConsoleBlacklistItem,
TagConsoleBlacklistSearchReq,
TagConsoleBlacklistSearchResp,
TagConsoleCreateReq,
TagConsoleFilter,
TagConsoleItem,
@@ -72,7 +77,7 @@ class TagConsoleService:
self.tags_service = tags_service
@staticmethod
def _validate_page(req: TagConsoleFilter) -> None:
def _validate_page(req: TagConsoleFilter | TagConsoleBlacklistSearchReq) -> None:
if req.page < 1 or req.page_size < 1 or req.page_size > MAX_PAGE_SIZE:
raise TagConsolePageParamsError()
@@ -479,10 +484,20 @@ class TagConsoleService:
items: list[TagConsoleReviewRef],
reject_reason: str,
tenant_id: int,
*,
skip_blacklist: bool = False,
) -> TagConsoleBatchResult:
if not (reject_reason or "").strip():
raise TagConsoleRejectReasonRequiredError()
return await self._batch_review(items, tenant_id, approve=False, reject_reason=reject_reason.strip())
if not skip_blacklist:
await TagBlacklistService.ensure_can_insert_async([item.name for item in items])
return await self._batch_review(
items,
tenant_id,
approve=False,
reject_reason=reject_reason.strip(),
skip_blacklist=skip_blacklist,
)
async def _batch_review(
self,
@@ -493,6 +508,7 @@ class TagConsoleService:
target_library_id: int | None = None,
reject_reason: str | None = None,
ack_similar: bool = False,
skip_blacklist: bool = False,
) -> TagConsoleBatchResult:
scope = await self._review_scope_or_full()
self._validate_batch(items)
@@ -517,11 +533,19 @@ class TagConsoleService:
if not rows:
result.failed.append(TagConsoleBatchFailure(name=item.name, reason="标签不存在或已被处理"))
continue
if any(row.review_status == REJECTED_STATUS for row in rows):
# The underlying flow only looks at pending rows and would report
# a bare "tag not found"; say what is actually wrong instead.
raise TagConsoleActionNotApplicableError()
knowledge_id = self._resolve_knowledge_id(rows, scope, spaces_by_review_tag)
pending_rows = [
row
for row in rows
if int(getattr(row, "review_status", PENDING_STATUS) or PENDING_STATUS) == PENDING_STATUS
]
# Same name may have an older rejected row (soft-delete). Acting on
# the new pending proposal must not fail the whole batch.
if not pending_rows:
result.failed.append(
TagConsoleBatchFailure(name=item.name, reason="该标签的当前状态不支持此操作")
)
continue
knowledge_id = self._resolve_knowledge_id(pending_rows, scope, spaces_by_review_tag)
if approve and knowledge_id is None:
# Logged, not silent: this rejects the whole item while still
# returning HTTP 200, which is invisible in the access log.
@@ -548,6 +572,7 @@ class TagConsoleService:
"tag_library_id": target_library_id,
"knowledge_id": knowledge_id,
"ack_similar": ack_similar if approve else False,
"skip_blacklist": skip_blacklist if not approve else False,
}
if reject_reason is not None:
payload["reject_reason"] = reject_reason
@@ -669,3 +694,55 @@ class TagConsoleService:
for library_id in sorted(set(library_ids)):
await TagLibraryTagService.sync_library_name_lists(library_id)
await TagLibraryTagService.invalidate_link_b_tenant_catalog_cache_async(tenant_id)
async def preview_blacklist(self, names: list[str]) -> dict:
await self._ensure_can_manage_tags()
preview = await TagBlacklistService.preview_insert_async(names)
return {
"count": preview.count,
"limit": preview.limit,
"new_count": preview.new_count,
"would_exceed": preview.would_exceed,
}
async def list_blacklist(self, req: TagConsoleBlacklistSearchReq) -> TagConsoleBlacklistSearchResp:
self._validate_page(req)
await self._ensure_can_manage_tags()
rows, total, count = await TagBlacklistService.search_async(
keyword=req.keyword,
page=req.page,
page_size=req.page_size,
)
return TagConsoleBlacklistSearchResp(
data=[
TagConsoleBlacklistItem(
id=int(row.id),
name=row.name,
user_id=int(row.user_id or 0),
create_time=row.create_time,
)
for row in rows
if row.id is not None
],
total=total,
count=count,
limit=TAG_BLACKLIST_MAX,
)
async def delete_blacklist(self, blacklist_id: int) -> bool:
await self._ensure_can_manage_tags()
await TagBlacklistService.delete_async(int(blacklist_id))
return True
async def add_blacklist(self, req: TagConsoleBlacklistCreateReq) -> TagConsoleBlacklistItem:
await self._ensure_can_manage_tags()
row = await TagBlacklistService.add_name_async(
req.name,
user_id=int(getattr(self.login_user, "user_id", 0) or 0),
)
return TagConsoleBlacklistItem(
id=int(row.id),
name=row.name,
user_id=int(row.user_id or 0),
create_time=row.create_time,
)
@@ -477,6 +477,11 @@ class WorkStationTagsService(BaseService):
)
if not pending:
raise ReviewTagNotFoundError.http_exception()
from bisheng.knowledge.domain.services.tag_blacklist_service import TagBlacklistService
skip_blacklist = bool(getattr(data, "skip_blacklist", False))
if not skip_blacklist:
await TagBlacklistService.ensure_can_insert_async([data.tag_name])
await self.review_tags_repository.reject_review_tag(
data.tag_name,
data.reject_reason,
@@ -489,6 +494,11 @@ class WorkStationTagsService(BaseService):
from bisheng.knowledge.domain.services.tag_library_tag_service import TagLibraryTagService
await TagLibraryTagService.invalidate_link_b_tenant_catalog_cache_async(tenant_id)
if not skip_blacklist:
await TagBlacklistService.add_names_async(
[data.tag_name],
user_id=int(getattr(self.login_user, "user_id", 0) or 0),
)
else:
raise ReviewTagTypeMismatchError.http_exception()
@@ -516,6 +526,21 @@ class WorkStationTagsService(BaseService):
)
return existed_tag_list
async def preview_tag_blacklist(self, names: list[str]) -> dict:
"""Reviewers need this before reject so they can skip insert when the cap would be hit."""
scope = await self.resolve_review_tag_scope()
if not scope.has_review_capacity():
raise ReviewTagPermissionDeniedError()
from bisheng.knowledge.domain.services.tag_blacklist_service import TagBlacklistService
preview = await TagBlacklistService.preview_insert_async(names)
return {
"count": preview.count,
"limit": preview.limit,
"new_count": preview.new_count,
"would_exceed": preview.would_exceed,
}
async def _list_in_scope_source_knowledge_ids(
self,
review_tags,
+13
View File
@@ -225,6 +225,18 @@ CREATE TABLE IF NOT EXISTS review_tag (
update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)"""
TABLE_TAG_BLACKLIST = """\
CREATE TABLE IF NOT EXISTS tag_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL DEFAULT 1,
name VARCHAR(255) NOT NULL,
name_key VARCHAR(255) NOT NULL,
user_id INTEGER DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
UNIQUE(tenant_id, name_key)
)"""
TABLE_REVIEW_TAG_LINK = """\
CREATE TABLE IF NOT EXISTS review_tag_link (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -941,6 +953,7 @@ TABLE_DEFINITIONS: dict[str, str] = {
"taglink": TABLE_TAG_LINK,
"review_tag": TABLE_REVIEW_TAG,
"review_tag_link": TABLE_REVIEW_TAG_LINK,
"tag_blacklist": TABLE_TAG_BLACKLIST,
"knowledge_space_tag_library": TABLE_KNOWLEDGE_SPACE_TAG_LIBRARY,
"department": TABLE_DEPARTMENT,
"user_department": TABLE_USER_DEPARTMENT,
@@ -18,6 +18,12 @@ from bisheng.knowledge.domain.services.knowledge_space_auto_tag_service import (
from bisheng.knowledge.domain.services.knowledge_space_service import KnowledgeSpaceService
@pytest.fixture(autouse=True)
def _empty_tag_blacklist():
with patch.object(KnowledgeSpaceAutoTagService, "_blacklist_catalog", return_value=[]):
yield
def _space_file(**kwargs) -> KnowledgeFile:
defaults = dict(
id=2,
@@ -19,6 +19,12 @@ from bisheng.knowledge.domain.services.knowledge_space_tag_library_service impor
)
@pytest.fixture(autouse=True)
def _empty_tag_blacklist():
with patch.object(KnowledgeSpaceAutoTagService, "_blacklist_catalog", return_value=[]):
yield
def test_tag_library_normalize_preserves_duplicates_and_rejects_over_limit():
assert KnowledgeSpaceTagLibraryService.normalize_tags([" 政策 ", "", "政策", "制度"]) == ["政策", "政策", "制度"]
@@ -1,6 +1,8 @@
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from bisheng.database.models.tag import TagResourceTypeEnum
from bisheng.knowledge.domain.models.knowledge import Knowledge, KnowledgeTypeEnum
from bisheng.knowledge.domain.models.knowledge_file import (
@@ -24,6 +26,12 @@ _LINK_DAO_PATCH = (
)
@pytest.fixture(autouse=True)
def _empty_tag_blacklist():
with patch.object(KnowledgeSpaceAutoTagService, "_blacklist_catalog", return_value=[]):
yield
def test_build_review_tag_system_prompt_appends_business_domain_and_file_category():
db_file = KnowledgeFile(
id=1,
@@ -0,0 +1,154 @@
from unittest.mock import AsyncMock, patch
import pytest
from bisheng.common.errcode.tag import (
TagBlacklistAlreadyExistError,
TagBlacklistLimitExceededError,
TagBlacklistNotFoundError,
TagNameParamsIsEmptyError,
)
from bisheng.knowledge.domain.models.knowledge import Knowledge, KnowledgeTypeEnum
from bisheng.knowledge.domain.models.knowledge_file import (
FileSource,
FileType,
KnowledgeFile,
KnowledgeFileStatus,
)
from bisheng.knowledge.domain.services.knowledge_space_auto_tag_service import KnowledgeSpaceAutoTagService
from bisheng.knowledge.domain.services.tag_blacklist_service import TAG_BLACKLIST_MAX, TagBlacklistService
def test_is_blocked_name_matches_exact_and_similar():
catalog = [("机密文件", "机密文件"), ("内部制度", "内部制度")]
assert TagBlacklistService.is_blocked_name("机密文件", catalog)
assert TagBlacklistService.is_blocked_name("机密文件汇编", catalog)
assert not TagBlacklistService.is_blocked_name("公开政策", catalog)
def test_filter_unblocked_names_drops_blacklist_and_near_matches():
catalog = [("涉密", "涉密")]
kept = TagBlacklistService.filter_unblocked_names(["政策", "涉密", "涉密材料", "制度"], catalog)
assert kept == ["政策", "制度"]
@pytest.mark.asyncio
async def test_preview_insert_async_reports_cap():
with (
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistDao.acount",
new=AsyncMock(return_value=999),
),
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistDao.alist_existing_name_keys",
new=AsyncMock(return_value=set()),
),
):
preview = await TagBlacklistService.preview_insert_async(["a", "b"])
assert preview.count == 999
assert preview.new_count == 2
assert preview.would_exceed is True
assert preview.limit == TAG_BLACKLIST_MAX
@pytest.mark.asyncio
async def test_ensure_can_insert_raises_when_over_limit():
with (
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistDao.acount",
new=AsyncMock(return_value=1000),
),
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistDao.alist_existing_name_keys",
new=AsyncMock(return_value=set()),
),
pytest.raises(TagBlacklistLimitExceededError),
):
await TagBlacklistService.ensure_can_insert_async(["机密"])
@pytest.mark.asyncio
async def test_preview_ignores_names_already_blacklisted():
with (
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistDao.acount",
new=AsyncMock(return_value=1000),
),
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistDao.alist_existing_name_keys",
new=AsyncMock(return_value={"机密"}),
),
):
preview = await TagBlacklistService.preview_insert_async(["机密"])
assert preview.new_count == 0
assert preview.would_exceed is False
@pytest.mark.asyncio
async def test_add_name_async_rejects_empty_and_duplicate():
with pytest.raises(TagNameParamsIsEmptyError):
await TagBlacklistService.add_name_async(" ", user_id=1)
with (
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistDao.alist_existing_name_keys",
new=AsyncMock(return_value={"机密"}),
),
pytest.raises(TagBlacklistAlreadyExistError),
):
await TagBlacklistService.add_name_async("机密", user_id=1)
@pytest.mark.asyncio
async def test_add_name_async_raises_when_at_limit():
with (
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistDao.alist_existing_name_keys",
new=AsyncMock(return_value=set()),
),
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistDao.acount",
new=AsyncMock(return_value=1000),
),
pytest.raises(TagBlacklistLimitExceededError),
):
await TagBlacklistService.add_name_async("新词", user_id=1)
@pytest.mark.asyncio
async def test_delete_missing_row_raises():
with patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistDao.aget",
new=AsyncMock(return_value=None),
):
with pytest.raises(TagBlacklistNotFoundError):
await TagBlacklistService.delete_async(1)
def test_recommend_filters_blacklisted_candidates_before_llm():
knowledge = Knowledge(id=1, name="space", type=KnowledgeTypeEnum.SPACE.value, tenant_id=1)
db_file = KnowledgeFile(
id=2,
knowledge_id=1,
file_name="a.txt",
file_type=FileType.FILE.value,
file_source=FileSource.UPLOAD.value,
status=KnowledgeFileStatus.SUCCESS.value,
user_id=7,
tenant_id=1,
abstract="政策制度内容",
)
candidates = ["政策", "制度", "机密", "内部", "公开", "流程", "标准", "规范", "指南", "手册", "办法", "细则"]
with (
patch.object(KnowledgeSpaceAutoTagService, "_resolve_library_ids", return_value=[10]),
patch.object(KnowledgeSpaceAutoTagService, "_collect_library_tags", return_value=(candidates, [])),
patch.object(
KnowledgeSpaceAutoTagService,
"_blacklist_catalog",
return_value=[("机密", "机密"), ("内部", "内部")],
),
patch.object(KnowledgeSpaceAutoTagService, "_invoke_llm") as invoke,
):
names = KnowledgeSpaceAutoTagService.recommend_bound_library_tags_sync(knowledge, db_file)
invoke.assert_not_called()
assert names == ["政策", "制度", "公开", "流程", "标准", "规范", "指南", "手册", "办法", "细则"]
@@ -210,7 +210,13 @@ async def test_reject_review_tag_notifies_submitters():
with patch(
"bisheng.workstation.domain.services.review_tag_notification_service.ReviewTagNotificationService.notify_after_decision",
new=AsyncMock(),
) as notify_after_decision:
) as notify_after_decision, patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistService.ensure_can_insert_async",
new=AsyncMock(),
), patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistService.add_names_async",
new=AsyncMock(),
):
await service.approve_or_reject_review_tag(data, tenant_id=1)
service.review_tags_repository.reject_review_tag.assert_awaited_once()
@@ -219,3 +225,46 @@ async def test_reject_review_tag_notifies_submitters():
assert kwargs["tag_name"] == "人工标签"
assert kwargs["reject_reason"] == "名称不规范"
assert kwargs["submitter_targets"][0].user_id == 88
@pytest.mark.asyncio
async def test_reject_skip_blacklist_does_not_insert():
service = _build_tags_service()
data = ApproveOrRejectRequest(
tag_name="人工标签",
status=ApproveOrRejectEnum.REJECT,
reject_reason="名称不规范",
resource_type=TagResourceTypeEnum.MANUAL_TAG,
skip_blacklist=True,
)
service.review_tags_repository.reject_review_tag = AsyncMock()
service.review_tags_repository.get_review_tag_list_by_tag_name = AsyncMock(
return_value=[SimpleNamespace(id=1, business_type="knowledge_space", business_id="137")],
)
service.review_tags_repository.list_submitter_notification_targets = AsyncMock(return_value=[])
ensure = AsyncMock()
add_names = AsyncMock()
with (
patch(
"bisheng.workstation.domain.services.review_tag_notification_service.ReviewTagNotificationService.notify_after_decision",
new=AsyncMock(),
),
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistService.ensure_can_insert_async",
new=ensure,
),
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistService.add_names_async",
new=add_names,
),
patch(
"bisheng.knowledge.domain.services.tag_library_tag_service.TagLibraryTagService.invalidate_link_b_tenant_catalog_cache_async",
new=AsyncMock(),
),
):
await service.approve_or_reject_review_tag(data, tenant_id=1)
service.review_tags_repository.reject_review_tag.assert_awaited_once()
ensure.assert_not_awaited()
add_names.assert_not_awaited()
@@ -458,6 +458,14 @@ async def test_reject_passes_scope_to_repository():
"bisheng.knowledge.domain.services.tag_library_tag_service.TagLibraryTagService.invalidate_link_b_tenant_catalog_cache_async",
new=AsyncMock(),
),
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistService.ensure_can_insert_async",
new=AsyncMock(),
),
patch(
"bisheng.knowledge.domain.services.tag_blacklist_service.TagBlacklistService.add_names_async",
new=AsyncMock(),
),
):
await service.approve_or_reject_review_tag(data, tenant_id=1)
@@ -12,7 +12,7 @@ Provenance lives on the file link instead — ``review_tag_link.resource_id`` ->
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
import pytest
@@ -110,12 +110,54 @@ async def test_reject_does_not_need_a_source_space():
"""Rejecting never writes into a library, so it must work without one."""
service, tags_service = _build_service(files_by_tag={}, briefs={})
result = await service.batch_reject([TAG], "不建议新增", TENANT_ID)
with patch(
"bisheng.workstation.domain.services.tag_console_service.TagBlacklistService.ensure_can_insert_async",
new=AsyncMock(),
):
result = await service.batch_reject([TAG], "不建议新增", TENANT_ID)
assert result.succeeded == 1
request = tags_service.approve_or_reject_review_tag.await_args.args[0]
assert request.status == ApproveOrRejectEnum.REJECT
assert request.reject_reason == "不建议新增"
assert request.skip_blacklist is False
@pytest.mark.asyncio
async def test_reject_ignores_older_rejected_row_for_same_name():
"""A previous reject leaves a soft-deleted row; a new pending proposal of the
same name must still be rejectable."""
pending = _review_row()
rejected = SimpleNamespace(**{**pending.__dict__, "id": 255, "review_status": 2})
service, tags_service = _build_service()
service.repository.load_review_group.return_value = {(TAG.name, TAG.resource_type): [pending, rejected]}
with patch(
"bisheng.workstation.domain.services.tag_console_service.TagBlacklistService.ensure_can_insert_async",
new=AsyncMock(),
):
result = await service.batch_reject([TAG], "不建议新增", TENANT_ID)
assert result.succeeded == 1
assert not result.failed
tags_service.approve_or_reject_review_tag.assert_awaited_once()
@pytest.mark.asyncio
async def test_reject_already_rejected_item_does_not_abort_batch():
rejected = SimpleNamespace(**{**_review_row().__dict__, "review_status": 2})
service, tags_service = _build_service()
service.repository.load_review_group.return_value = {(TAG.name, TAG.resource_type): [rejected]}
with patch(
"bisheng.workstation.domain.services.tag_console_service.TagBlacklistService.ensure_can_insert_async",
new=AsyncMock(),
):
result = await service.batch_reject([TAG], "不建议新增", TENANT_ID)
assert result.succeeded == 0
assert [failure.reason for failure in result.failed] == ["该标签的当前状态不支持此操作"]
tags_service.approve_or_reject_review_tag.assert_not_awaited()
@pytest.mark.asyncio
@@ -1503,6 +1503,17 @@
"tagConsole": {
"searchLibrary": "Search tag library",
"pendingEntry": "Pending tags",
"blacklistAdd": "Add",
"blacklistAddTitle": "Add to blacklist",
"blacklistLimitReached": "The blacklist has reached the {{limit}} limit",
"blacklistSearch": "Search blacklist",
"blacklistEmpty": "No blacklisted tags",
"blacklistCount": "{{count}} / {{limit}}",
"blacklistRemove": "Remove",
"blacklistDeleteConfirm": "Remove this tag from the blacklist?",
"blacklistLimitTitle": "Blacklist would exceed the limit",
"blacklistLimitConfirm": "The blacklist currently has {{count}} entries. This reject would add {{newCount}}, exceeding the {{limit}} limit. Skip adding to the blacklist and reject anyway?",
"skipBlacklistAndReject": "Skip and reject",
"noLibrary": "No tag library",
"noBoundSpace": "No linked knowledge space",
"status": "Status",
@@ -2209,6 +2220,10 @@
"10640": "Cannot modify admin account",
"10700": "Tag already exists",
"10701": "Tag not found",
"10709": "Tag name cannot be empty",
"10715": "Tag blacklist would exceed the 1000-item limit",
"10716": "Tag blacklist entry not found",
"10717": "This tag is already in the blacklist",
"10800": "Provider name already exists",
"10801": "Duplicate model",
"10802": "Failed to add provider. All models failed to initialize",
@@ -1486,6 +1486,17 @@
"tagConsole": {
"searchLibrary": "タグライブラリを検索",
"pendingEntry": "審査待ちタグ",
"blacklistAdd": "追加",
"blacklistAddTitle": "ブラックリストに追加",
"blacklistLimitReached": "ブラックリストは上限 {{limit}} 件に達しています",
"blacklistSearch": "ブラックリストを検索",
"blacklistEmpty": "ブラックリストのタグはありません",
"blacklistCount": "{{count}} / {{limit}}",
"blacklistRemove": "削除",
"blacklistDeleteConfirm": "このタグをブラックリストから削除しますか?",
"blacklistLimitTitle": "ブラックリストの上限を超えます",
"blacklistLimitConfirm": "ブラックリストは現在 {{count}} 件です。今回 {{newCount}} 件追加すると上限 {{limit}} 件を超えます。ブラックリストへの追加をせずに却下しますか?",
"skipBlacklistAndReject": "追加せず却下",
"noLibrary": "タグライブラリなし",
"noBoundSpace": "関連ナレッジスペースなし",
"status": "ステータス",
@@ -2154,6 +2165,10 @@
"10640": "管理者ユーザーは変更できません",
"10700": "タグがすでに存在します",
"10701": "タグが見つかりません",
"10709": "タグ名は必須です",
"10715": "ブラックリストは上限 1000 件を超えます",
"10716": "ブラックリストの項目が見つかりません",
"10717": "このタグはすでにブラックリストにあります",
"10800": "提供元名が重複しています",
"10801": "モデルが重複しています",
"10802": "提供元追加に失敗(全モデルの初期化失敗)",
@@ -1492,6 +1492,17 @@
"tagConsole": {
"searchLibrary": "搜索标签库名",
"pendingEntry": "待审核标签",
"blacklistAdd": "添加",
"blacklistAddTitle": "添加黑名单",
"blacklistLimitReached": "黑名单已达 {{limit}} 条上限",
"blacklistSearch": "搜索黑名单",
"blacklistEmpty": "暂无黑名单标签",
"blacklistCount": "{{count}} / {{limit}}",
"blacklistRemove": "移除",
"blacklistDeleteConfirm": "确定从黑名单中移除该标签?",
"blacklistLimitTitle": "黑名单将超过上限",
"blacklistLimitConfirm": "黑名单当前 {{count}} 条,本次将新增 {{newCount}} 条,超过 {{limit}} 条上限。是否放弃插入黑名单并直接驳回?",
"skipBlacklistAndReject": "放弃插入并驳回",
"noLibrary": "暂无标签库",
"noBoundSpace": "暂无关联知识空间",
"status": "标签状态",
@@ -2154,6 +2165,10 @@
"10640": "不能修改管理员用户信息",
"10700": "标签已存在",
"10701": "未找到对应的标签",
"10709": "标签名称不能为空",
"10715": "黑名单将超过 1000 条上限",
"10716": "黑名单记录不存在",
"10717": "该标签已在黑名单中",
"10800": "服务提供方名称重复,请修改",
"10801": "模型不可重复",
"10802": "添加服务提供方失败,模型全部初始化失败",
@@ -245,6 +245,7 @@ export async function approveOrRejectReviewTagApi(data: {
tag_library_id?: number
knowledge_id?: number
ack_similar?: boolean
skip_blacklist?: boolean
}): Promise<boolean> {
return await axios.post("/api/v1/workstation/tags/approve_or_reject", data)
}
@@ -443,13 +444,56 @@ export async function batchApproveTagConsoleApi(
export async function batchRejectTagConsoleApi(
items: TagConsoleReviewRef[],
rejectReason: string,
skipBlacklist = false,
): Promise<TagConsoleBatchResult> {
return await axios.post("/api/v1/workstation/tags/console/review/batch-reject", {
items,
reject_reason: rejectReason,
skip_blacklist: skipBlacklist,
})
}
export interface TagBlacklistItem {
id: number
name: string
user_id?: number
create_time?: string | null
}
export interface TagBlacklistSearchResp {
data: TagBlacklistItem[]
total: number
count: number
limit: number
}
export interface TagBlacklistPreviewResp {
count: number
limit: number
new_count: number
would_exceed: boolean
}
export async function searchTagBlacklistApi(params: {
keyword?: string
page?: number
page_size?: number
}): Promise<TagBlacklistSearchResp> {
return await axios.get("/api/v1/workstation/tags/console/blacklist", { params })
}
export async function previewTagBlacklistApi(names: string[]): Promise<TagBlacklistPreviewResp> {
return await axios.post("/api/v1/workstation/tags/blacklist/preview", { names })
}
export async function addTagBlacklistApi(name: string): Promise<TagBlacklistItem> {
return await axios.post("/api/v1/workstation/tags/console/blacklist", { name })
}
export async function deleteTagBlacklistApi(id: number): Promise<boolean> {
return await axios.delete(`/api/v1/workstation/tags/console/blacklist/${id}`)
}
export async function getTagConsolePendingCountApi(): Promise<{ pending_count: number }> {
return await axios.get("/api/v1/workstation/tags/console/review/pending-count")
}
@@ -14,6 +14,7 @@ import { Check, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApproveReviewTagDialog } from "./reviewTag/ApproveReviewTagDialog";
import { confirmRejectSkipBlacklist } from "./standalone/tagConsole/tagBlacklistConfirm";
const PAGE_SIZE = 5;
@@ -132,8 +133,15 @@ export default function KnowledgeSpaceReviewTagSection({
const handleReject = async (row: ReviewTagItem) => {
if (!row.tag_name?.length) return;
const decision = await confirmRejectSkipBlacklist([row.tag_name], t);
if (!decision) return;
const res = await captureAndAlertRequestErrorHoc(
approveOrRejectReviewTagApi({ tag_name: row.tag_name || "", status: 2, resource_type: row.resource_type || "" }),
approveOrRejectReviewTagApi({
tag_name: row.tag_name || "",
status: 2,
resource_type: row.resource_type || "",
skip_blacklist: decision.skipBlacklist,
}),
);
if (res) {
toast({ variant: "success", description: t("build.rejected", "已拒绝") });
@@ -13,11 +13,13 @@ import { captureAndAlertRequestErrorHoc } from "@/controllers/request"
import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { ReviewTablePanel } from "./tagConsole/ReviewTablePanel"
import { TagBlacklistPanel } from "./tagConsole/TagBlacklistPanel"
import { TagFeatureToggles } from "./tagConsole/TagFeatureToggles"
import { TagLibraryPanel } from "./tagConsole/TagLibraryPanel"
import { TagTablePanel } from "./tagConsole/TagTablePanel"
import {
INITIAL_SELECTION,
selectBlacklistEntry,
selectLibrary,
selectReviewEntry,
type TagConsoleSelection,
@@ -86,12 +88,15 @@ export default function KnowledgeTagLibraryPage() {
pendingCount={pendingCount}
onSelectLibrary={(libraryId) => setSelection((prev) => selectLibrary(prev, libraryId))}
onSelectReviewEntry={() => setSelection(selectReviewEntry())}
onSelectBlacklistEntry={() => setSelection(selectBlacklistEntry())}
onLibrariesChanged={handleLibrariesChanged}
refreshToken={libraryRefreshToken}
/>
{selection.mode === "review" ? (
<ReviewTablePanel libraries={libraries} onReviewed={handleTagsChanged} />
) : selection.mode === "blacklist" ? (
<TagBlacklistPanel />
) : (
<TagTablePanel
selectedLibraryIds={selection.selectedLibraryIds}
@@ -0,0 +1,63 @@
import { Button } from "@/components/bs-ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/bs-ui/dialog"
import { Input } from "@/components/bs-ui/input"
import { Label } from "@/components/bs-ui/label"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
interface AddBlacklistDialogProps {
open: boolean
saving: boolean
onOpenChange: (open: boolean) => void
onConfirm: (name: string) => void
}
export function AddBlacklistDialog({ open, saving, onOpenChange, onConfirm }: AddBlacklistDialogProps) {
const { t } = useTranslation()
const [name, setName] = useState("")
useEffect(() => {
if (open) setName("")
}, [open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-0 p-0 sm:max-w-[460px] bg-background-login">
<DialogHeader className="border-b border-[#EBECF0] px-6 py-4">
<DialogTitle>{t("build.tagConsole.blacklistAddTitle", "添加黑名单")}</DialogTitle>
</DialogHeader>
<div className="px-6 py-5">
<Label className="bisheng-label">
{t("build.tagName", "标签名称")}
<span className="bisheng-tip">*</span>
</Label>
<Input
className="mt-2"
value={name}
maxLength={64}
autoComplete="off"
placeholder={t("build.tagConsole.tagNamePlaceholder", "请输入标签名称")}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && name.trim() && !saving) {
onConfirm(name.trim())
}
}}
/>
</div>
<DialogFooter className="border-t border-[#EBECF0] px-6 py-3">
<Button variant="outline" className="px-8" onClick={() => onOpenChange(false)}>
{t("cancel", { ns: "bs" })}
</Button>
<Button
className="px-8"
disabled={saving || !name.trim()}
onClick={() => onConfirm(name.trim())}
>
{t("confirm", { ns: "bs" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -20,6 +20,7 @@ import { BatchApproveLibraryPickerDialog, BatchResultDialog, RejectReasonDialog
import { TagFilterBar } from "./TagFilterBar"
import { TagReviewDialog } from "./TagReviewDialog"
import { TagSourceIcon } from "./TagSourceIcon"
import { confirmRejectSkipBlacklist } from "./tagBlacklistConfirm"
import {
buildSearchParams,
EMPTY_FILTERS,
@@ -161,10 +162,14 @@ export function ReviewTablePanel({ libraries, onReviewed }: ReviewTablePanelProp
}
const handleReject = async (reason: string, items: TagConsoleReviewRef[]) => {
const decision = await confirmRejectSkipBlacklist(items.map((item) => item.name), t)
if (!decision) return
setSaving(true)
setRejectOpen(false)
setReviewTarget(null)
finishBatch(await captureAndAlertRequestErrorHoc(batchRejectTagConsoleApi(items, reason)))
finishBatch(await captureAndAlertRequestErrorHoc(
batchRejectTagConsoleApi(items, reason, decision.skipBlacklist),
))
}
const { libraries: approvableLibraries, loading: loadingLibraries } = useApprovableLibraries()
@@ -32,14 +32,14 @@ export function SourceFileLinks({ files, max = 3 }: { files: TagConsoleSourceFil
const rest = files.length - shown.length
return (
<div className="flex flex-col gap-0.5">
<div className="flex min-w-0 flex-col gap-0.5">
{shown.map((file) => {
if (isViolationFile(file)) {
return (
<button
key={file.file_id}
type="button"
className="truncate text-left text-[#F53F3F] hover:underline"
className="block w-full truncate text-left text-[#F53F3F] hover:underline"
title={t("build.tagConsole.violationBlocked", "该文件包含违规内容,无法预览")}
onClick={() => setViolating(file)}
>
@@ -50,7 +50,7 @@ export function SourceFileLinks({ files, max = 3 }: { files: TagConsoleSourceFil
const url = buildTagFileDetailUrl(file)
if (!url) {
return (
<span key={file.file_id} className="truncate">
<span key={file.file_id} className="block truncate" title={file.file_name}>
{file.file_name}
</span>
)
@@ -61,7 +61,8 @@ export function SourceFileLinks({ files, max = 3 }: { files: TagConsoleSourceFil
href={url}
target="_blank"
rel="noopener noreferrer"
className="truncate text-blue-600 hover:underline"
title={file.file_name}
className="block truncate text-blue-600 hover:underline"
>
{file.file_name}
</a>
@@ -0,0 +1,192 @@
import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm"
import { Button } from "@/components/bs-ui/button"
import { SearchInput } from "@/components/bs-ui/input"
import AutoPagination from "@/components/bs-ui/pagination/autoPagination"
import { useToast } from "@/components/bs-ui/toast/use-toast"
import {
addTagBlacklistApi,
deleteTagBlacklistApi,
searchTagBlacklistApi,
type TagBlacklistItem,
} from "@/controllers/API/knowledgeSpaceTagLibrary"
import { captureAndAlertRequestErrorHoc } from "@/controllers/request"
import { Trash2 } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { AddBlacklistDialog } from "./AddBlacklistDialog"
import { formatDateTime } from "./tagConsoleTypes"
const DEFAULT_PAGE_SIZE = 20
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
export function TagBlacklistPanel() {
const { t } = useTranslation()
const { toast } = useToast()
const [keyword, setKeyword] = useState("")
const [appliedKeyword, setAppliedKeyword] = useState("")
const [rows, setRows] = useState<TagBlacklistItem[]>([])
const [total, setTotal] = useState(0)
const [count, setCount] = useState(0)
const [limit, setLimit] = useState(1000)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [loading, setLoading] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [saving, setSaving] = useState(false)
const load = useCallback(
async (targetPage: number) => {
setLoading(true)
const res = await captureAndAlertRequestErrorHoc(
searchTagBlacklistApi({
keyword: appliedKeyword.trim() || undefined,
page: targetPage,
page_size: pageSize,
}),
)
setRows(res?.data || [])
setTotal(res?.total || 0)
setCount(res?.count || 0)
setLimit(res?.limit || 1000)
setLoading(false)
},
[appliedKeyword, pageSize],
)
useEffect(() => {
setPage(1)
void load(1)
}, [load])
const handleSearch = () => {
setAppliedKeyword(keyword)
}
const handleOpenAdd = () => {
if (count >= limit) {
toast({
variant: "error",
description: t("build.tagConsole.blacklistLimitReached", "黑名单已达 {{limit}} 条上限", { limit }),
})
return
}
setAddOpen(true)
}
const handleAdd = async (name: string) => {
setSaving(true)
const res = await captureAndAlertRequestErrorHoc(addTagBlacklistApi(name))
setSaving(false)
if (!res) return
toast({ variant: "success", description: t("build.saved", "已保存") })
setAddOpen(false)
setPage(1)
void load(1)
}
const handleDelete = (row: TagBlacklistItem) => {
bsConfirm({
title: t("build.tagConsole.blacklistRemove", "移除"),
desc: t("build.tagConsole.blacklistDeleteConfirm", "确定从黑名单中移除该标签?"),
showClose: true,
okTxt: t("build.confirmDelete", "确认删除"),
canelTxt: t("cancel", { ns: "bs" }),
async onOk(next) {
const res = await captureAndAlertRequestErrorHoc(deleteTagBlacklistApi(row.id))
if (res) {
toast({ variant: "success", description: t("build.deleted", "已删除") })
void load(page)
}
next?.()
},
})
}
return (
<div className="flex h-full min-w-0 flex-1 flex-col">
<div className="flex items-center gap-3 border-b border-[#E5E6EB] bg-background px-4 py-2.5">
<SearchInput
className="w-64"
placeholder={t("build.tagConsole.blacklistSearch", "搜索黑名单")}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button size="sm" onClick={handleSearch}>
{t("build.tagConsole.search", "搜索")}
</Button>
<Button size="sm" onClick={handleOpenAdd}>
{t("build.tagConsole.blacklistAdd", "添加")}
</Button>
<span className="ml-auto text-sm text-[#86909C]">
{t("build.tagConsole.blacklistCount", "{{count}} / {{limit}}", { count, limit })}
</span>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 z-10 bg-[#F7F8FA]">
<tr className="border-b border-[#E5E6EB] text-left text-xs uppercase tracking-wide text-[#86909C]">
<th className="w-14 px-3 py-3 font-medium">{t("build.tagConsole.index", "序号")}</th>
<th className="px-3 py-3 font-medium">{t("build.tagName", "标签名称")}</th>
<th className="w-48 px-3 py-3 font-medium">{t("build.tagConsole.createDate", "创建日期")}</th>
<th className="w-20 px-3 py-3 font-medium">{t("build.tagConsole.handle", "处理")}</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={4} className="px-3 py-10 text-center text-muted-foreground">
{t("loading")}
</td>
</tr>
) : !rows.length ? (
<tr>
<td colSpan={4} className="px-3 py-10 text-center text-muted-foreground">
{t("build.tagConsole.blacklistEmpty", "暂无黑名单标签")}
</td>
</tr>
) : (
rows.map((row, index) => (
<tr key={row.id} className="border-b border-[#F2F3F5]">
<td className="px-3 py-3 text-[#86909C]">{(page - 1) * pageSize + index + 1}</td>
<td className="px-3 py-3">{row.name}</td>
<td className="px-3 py-3 text-[#86909C]">{formatDateTime(row.create_time)}</td>
<td className="px-3 py-3">
<button type="button" onClick={() => handleDelete(row)}>
<Trash2 className="size-4 text-muted-foreground hover:text-red-500" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex justify-end border-t border-[#E5E6EB] bg-background px-4 py-2">
<AutoPagination
page={page}
pageSize={pageSize}
total={total}
showJumpInput
jumpToText={t("pagination.jumpTo", "跳至")}
pageText={t("pagination.pageUnit", "页")}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={setPageSize}
onChange={(value) => {
setPage(value)
void load(value)
}}
/>
</div>
<AddBlacklistDialog
open={addOpen}
saving={saving}
onOpenChange={setAddOpen}
onConfirm={handleAdd}
/>
</div>
)
}
@@ -14,7 +14,7 @@ import {
} from "@/controllers/API/knowledgeSpaceTagLibrary"
import { captureAndAlertRequestErrorHoc } from "@/controllers/request"
import { cname } from "@/components/bs-ui/utils"
import { ClipboardCheck, GripVertical, Pencil, Plus, Trash2 } from "lucide-react"
import { Ban, ClipboardCheck, GripVertical, Pencil, Plus, Trash2 } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { DragDropContext, Draggable, Droppable, type DropResult } from "react-beautiful-dnd"
import { useTranslation } from "react-i18next"
@@ -29,6 +29,7 @@ interface TagLibraryPanelProps {
pendingCount: number
onSelectLibrary: (libraryId: number) => void
onSelectReviewEntry: () => void
onSelectBlacklistEntry: () => void
/** Bubbles up so the right panel can drop a library that no longer exists. */
onLibrariesChanged: (libraries: KnowledgeSpaceTagLibraryListItem[]) => void
/**
@@ -47,6 +48,7 @@ export function TagLibraryPanel({
pendingCount,
onSelectLibrary,
onSelectReviewEntry,
onSelectBlacklistEntry,
onLibrariesChanged,
refreshToken,
}: TagLibraryPanelProps) {
@@ -195,6 +197,22 @@ export function TagLibraryPanel({
)}
</button>
<button
type="button"
onClick={onSelectBlacklistEntry}
className={cname(
"flex items-center justify-between border-l-[3px] border-b border-b-[#E5E6EB] px-4 py-3 text-left text-sm transition-colors",
mode === "blacklist"
? "border-l-primary bg-primary/10 font-medium text-primary"
: "border-l-transparent hover:bg-[#F2F3F5]",
)}
>
<span className="flex items-center gap-2">
<Ban className="size-4" />
{t("build.tagConsole.blacklistEntry", "标签黑名单")}
</span>
</button>
<div className="flex-1 overflow-y-auto">
{loading ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">{t("loading")}</p>
@@ -114,15 +114,15 @@ export function TagReviewDialog({ target, libraries, saving, onClose, onApprove,
}
const field = (label: string, value: React.ReactNode) => (
<div className="flex gap-2 py-1 text-sm">
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 flex-1">{value}</span>
<div className="flex items-start gap-3 py-1 text-sm">
<span className="w-28 shrink-0 leading-6 text-muted-foreground">{label}</span>
<span className="min-w-0 flex-1 overflow-hidden leading-6">{value}</span>
</div>
)
return (
<Dialog open={Boolean(target)} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="gap-0 p-0 sm:max-w-[640px] bg-background-login">
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px] bg-background-login">
<DialogHeader className="border-b border-[#EBECF0] px-6 py-4">
<DialogTitle>{t("build.tagConsole.reviewTitle", "标签审核")}</DialogTitle>
</DialogHeader>
@@ -132,7 +132,7 @@ export function TagReviewDialog({ target, libraries, saving, onClose, onApprove,
<p className="py-6 text-center text-sm text-muted-foreground">{t("loading")}</p>
) : (
<>
<div className="rounded-lg border border-[#ECECEC] bg-[#FAFBFC] p-4">
<div className="overflow-hidden rounded-lg border border-[#ECECEC] bg-[#FAFBFC] p-4">
{field(t("build.tagName", "标签名称"), detail?.name || "-")}
{field(
t("build.tagConsole.tagType", "标签类型"),
@@ -184,7 +184,7 @@ export function TagReviewDialog({ target, libraries, saving, onClose, onApprove,
)}
</div>
<DialogFooter className="border-t border-[#EBECF0] px-6 py-3">
<DialogFooter className="flex-row justify-end gap-3 space-x-0 border-t border-[#EBECF0] px-6 py-3">
<Button variant="outline" className="px-8" onClick={onClose}>
{t("cancel", { ns: "bs" })}
</Button>
@@ -0,0 +1,53 @@
import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm"
import { previewTagBlacklistApi } from "@/controllers/API/knowledgeSpaceTagLibrary"
import { captureAndAlertRequestErrorHoc } from "@/controllers/request"
import type { TFunction } from "i18next"
export type RejectBlacklistDecision = { skipBlacklist: boolean }
/**
* Ask whether to skip writing rejected names into the blacklist when the
* 1000-row cap would be exceeded. Cancel leaves the reject unsent.
*/
export async function confirmRejectSkipBlacklist(
names: string[],
t: TFunction,
): Promise<RejectBlacklistDecision | null> {
const preview = await captureAndAlertRequestErrorHoc(previewTagBlacklistApi(names))
if (!preview) return null
if (!preview.would_exceed) return { skipBlacklist: false }
return new Promise((resolve) => {
let settled = false
const finish = (value: RejectBlacklistDecision | null) => {
if (settled) return
settled = true
resolve(value)
}
bsConfirm({
title: t("build.tagConsole.blacklistLimitTitle", "黑名单将超过上限"),
desc: t(
"build.tagConsole.blacklistLimitConfirm",
"黑名单当前 {{count}} 条,本次将新增 {{newCount}} 条,超过 {{limit}} 条上限。是否放弃插入黑名单并直接驳回?",
{
count: preview.count,
newCount: preview.new_count,
limit: preview.limit,
},
),
showClose: true,
okTxt: t("build.tagConsole.skipBlacklistAndReject", "放弃插入并驳回"),
canelTxt: t("cancel", { ns: "bs" }),
onOk(next) {
finish({ skipBlacklist: true })
next?.()
},
onCancel() {
finish(null)
},
onClose() {
finish(null)
},
})
})
}
@@ -8,7 +8,7 @@ import type {
import { getWorkspaceClientUrl } from "@/utils/workspaceUrl"
/** Which table the right panel is showing. */
export type TagConsoleMode = "library" | "review"
export type TagConsoleMode = "library" | "review" | "blacklist"
/** Which listing the review panel's tab bar is on. */
export type TagConsoleReviewTab = "pending" | "reviewed"
@@ -82,6 +82,11 @@ export function selectReviewEntry(): TagConsoleSelection {
return { mode: "review", selectedLibraryIds: [] }
}
/** Click the fixed blacklist entry: enters blacklist mode, clears libraries. */
export function selectBlacklistEntry(): TagConsoleSelection {
return { mode: "blacklist", selectedLibraryIds: [] }
}
/** Drop empty values so the backend does not receive `""` as a real filter. */
export function buildSearchParams(
filters: TagConsoleFilterState,
@@ -19,6 +19,7 @@ import {
sourceLibraryNames,
selectLibrary,
selectReviewEntry,
selectBlacklistEntry,
type TagConsoleFilterState,
} from "@/pages/BuildPage/bench/standalone/tagConsole/tagConsoleTypes"
import {
@@ -52,6 +53,15 @@ describe("left panel selection", () => {
expect(withLibraries.selectedLibraryIds).toEqual([10, 20])
})
it("the blacklist entry clears library selection", () => {
const withLibraries = selectLibrary(selectLibrary(INITIAL_SELECTION, 10), 20)
const blacklist = selectBlacklistEntry()
expect(blacklist).toEqual({ mode: "blacklist", selectedLibraryIds: [] })
expect(withLibraries.selectedLibraryIds).toEqual([10, 20])
})
it("picking a library while in review mode returns to library mode", () => {
const back = selectLibrary(selectReviewEntry(), 10)