mirror of
https://github.com/dataelement/bisheng.git
synced 2026-08-29 01:22:31 +08:00
feat: 增加标签黑名单功能
This commit is contained in:
@@ -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
@@ -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}
|
||||
|
||||
+63
@@ -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>
|
||||
)
|
||||
}
|
||||
+6
-1
@@ -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()
|
||||
|
||||
+5
-4
@@ -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>
|
||||
|
||||
+192
@@ -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>
|
||||
)
|
||||
}
|
||||
+19
-1
@@ -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>
|
||||
|
||||
+6
-6
@@ -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>
|
||||
|
||||
+53
@@ -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)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
+6
-1
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user