feat: 将未分类FAQ条目转为"未分类"标签

This commit is contained in:
wizardchen
2026-01-07 11:08:59 +08:00
parent 9af1fc18f5
commit b71e7084fd
16 changed files with 187 additions and 179 deletions
+1 -1
View File
@@ -220,7 +220,7 @@ deps:
# Build for production
build-prod:
VERSION=$${VERSION:-unknown}; \
VERSION=$${VERSION:-$$(git describe --tags --abbrev=0 2>/dev/null || echo "unknown")}; \
COMMIT_ID=$${COMMIT_ID:-unknown}; \
BUILD_TIME=$${BUILD_TIME:-unknown}; \
GO_VERSION=$${GO_VERSION:-unknown}; \
+1
View File
@@ -1114,6 +1114,7 @@ export default {
questionIndexModeDescription: 'Combined: Standard and similar questions are indexed together. Separate: Each question is indexed independently for more precise retrieval but requires more storage.',
entryGuide: 'Each FAQ entry contains a primary question, similar questions, negative examples, and multiple answers. Manage them in the FAQ knowledge base detail view.',
tagDesc: 'Select category for FAQ entries',
tagPlaceholder: 'Please select a category',
modes: {
questionOnly: 'Questions only',
questionAnswer: 'Question + answer',
+1
View File
@@ -1238,6 +1238,7 @@ export default {
questionIndexModeDescription: "병합 인덱스: 표준 질문과 유사 질문을 병합 인덱싱; 개별 인덱스: 표준 질문과 각 유사 질문을 독립적으로 인덱싱하여 더 정확하게 검색하지만 더 많은 저장 공간이 필요합니다",
entryGuide: "FAQ 항목은 표준 질문, 유사 질문, 반례 및 여러 답변으로 구성됩니다. 지식베이스 세부 정보에서 일괄 가져오기 및 편집할 수 있습니다.",
tagDesc: "FAQ 항목에 분류 선택",
tagPlaceholder: "분류를 선택하세요",
modes: {
questionOnly: "표준 질문/유사 질문만",
questionAnswer: "표준 질문 + 답변",
+1
View File
@@ -1056,6 +1056,7 @@ export default {
questionIndexModeDescription: 'Объединенная: стандартные и похожие вопросы индексируются вместе. Раздельная: каждый вопрос индексируется независимо для более точного поиска, но требует больше места.',
entryGuide: 'Каждый FAQ включает основной вопрос, похожие вопросы, негативные примеры и несколько ответов. Управляйте ими в деталях FAQ-базы.',
tagDesc: 'Выберите категорию для записей FAQ',
tagPlaceholder: 'Пожалуйста, выберите категорию',
modes: {
questionOnly: 'Только вопросы',
questionAnswer: 'Вопрос + ответ',
+1
View File
@@ -1463,6 +1463,7 @@ export default {
questionIndexModeDescription: "合并索引:标准问和相似问合并索引;分别索引:标准问和每个相似问独立索引,检索更精确但需要更多存储",
entryGuide: "FAQ 条目由标准问、相似问、反例和多个答案组成,可在知识库详情中批量导入、编辑。",
tagDesc: "为 FAQ 条目选择分类",
tagPlaceholder: "请选择分类",
modes: {
questionOnly: "仅标准问/相似问",
questionAnswer: "标准问 + 答案",
+21 -51
View File
@@ -46,7 +46,7 @@ let knowledgeScroll = ref()
let page = 1;
let pageSize = 35;
const selectedTagId = ref<string>("__untagged__");
const selectedTagId = ref<string>('');
const tagList = ref<any[]>([]);
const tagLoading = ref(false);
const tagSearchQuery = ref('');
@@ -59,7 +59,6 @@ let tagSearchDebounce: ReturnType<typeof setTimeout> | null = null;
let docSearchDebounce: ReturnType<typeof setTimeout> | null = null;
const docSearchKeyword = ref('');
const selectedFileType = ref('');
const UNTAGGED_TAG_ID = '__untagged__';
const fileTypeOptions = computed(() => [
{ content: t('knowledgeBase.allFileTypes') || '全部类型', value: '' },
{ content: 'PDF', value: 'pdf' },
@@ -71,18 +70,12 @@ const fileTypeOptions = computed(() => [
{ content: t('knowledgeBase.typeManual') || '手动创建', value: 'manual' },
]);
type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: () => void }>;
const tagDropdownOptions = computed(() => {
const options = [
{ content: t('knowledgeBase.untagged') || '未分类', value: UNTAGGED_TAG_ID },
...tagList.value
.filter((tag: any) => tag.id !== UNTAGGED_TAG_ID)
.map((tag: any) => ({
content: tag.name,
value: tag.id,
})),
];
return options;
});
const tagDropdownOptions = computed(() =>
tagList.value.map((tag: any) => ({
content: tag.name,
value: tag.id,
})),
);
const tagMap = computed<Record<string, any>>(() => {
const map: Record<string, any> = {};
tagList.value.forEach((tag) => {
@@ -124,10 +117,9 @@ const getPageSize = () => {
getPageSize()
// 直接调用 API 获取知识库文件列表
const getTagName = (tagId?: string | number) => {
if (!tagId && tagId !== 0) return t('knowledgeBase.untagged') || '未分类';
if (tagId === UNTAGGED_TAG_ID) return t('knowledgeBase.untagged') || '未分类';
if (!tagId && tagId !== 0) return '';
const key = String(tagId);
return tagMap.value[key]?.name || (t('knowledgeBase.untagged') || '未分类');
return tagMap.value[key]?.name || '';
};
const formatDocTime = (time?: string) => {
@@ -225,22 +217,6 @@ const handleTagFilterChange = (value: string) => {
};
const handleTagRowClick = (tagId: string) => {
const normalizedId = String(tagId);
if (editingTagId.value && editingTagId.value !== normalizedId) {
editingTagId.value = null;
editingTagName.value = '';
}
if (creatingTag.value) {
creatingTag.value = false;
newTagName.value = '';
}
if (selectedTagId.value === normalizedId) {
return;
}
handleTagFilterChange(normalizedId);
};
const handleUntaggedClick = () => {
if (creatingTag.value) {
creatingTag.value = false;
newTagName.value = '';
@@ -249,8 +225,8 @@ const handleUntaggedClick = () => {
editingTagId.value = null;
editingTagName.value = '';
}
if (selectedTagId.value === UNTAGGED_TAG_ID) return;
handleTagFilterChange(UNTAGGED_TAG_ID);
if (selectedTagId.value === tagId) return;
handleTagFilterChange(tagId);
};
const startCreateTag = () => {
@@ -360,7 +336,9 @@ const confirmDeleteTag = (tag: any) => {
.then(() => {
MessagePlugin.success(t('knowledgeBase.tagDeleteSuccess'));
if (selectedTagId.value === tag.id) {
handleTagFilterChange(UNTAGGED_TAG_ID);
// Reset to show all entries when current tag is deleted
selectedTagId.value = '';
handleTagFilterChange('');
}
loadTags(kbId.value);
loadKnowledgeFiles(kbId.value);
@@ -372,8 +350,8 @@ const confirmDeleteTag = (tag: any) => {
const handleKnowledgeTagChange = async (knowledgeId: string, tagValue: string) => {
try {
// When selecting "untagged", pass null to clear the tag
const tagIdToUpdate = tagValue === UNTAGGED_TAG_ID ? null : (tagValue || null);
// Pass the tag value directly (empty string means no tag)
const tagIdToUpdate = tagValue || null;
await updateKnowledgeTagBatch({ updates: { [knowledgeId]: tagIdToUpdate } });
MessagePlugin.success(t('knowledgeBase.tagUpdateSuccess') || '分类已更新');
loadKnowledgeFiles(kbId.value);
@@ -392,7 +370,7 @@ const loadKnowledgeBaseInfo = async (targetKbId: string) => {
try {
const res: any = await getKnowledgeBaseById(targetKbId);
kbInfo.value = res?.data || null;
selectedTagId.value = UNTAGGED_TAG_ID;
selectedTagId.value = '';
if (!isFAQ.value) {
loadKnowledgeFiles(targetKbId);
} else {
@@ -1042,16 +1020,12 @@ async function createNewSession(value: string): Promise<void> {
v-for="tag in filteredTags"
:key="tag.id"
class="tag-list-item"
:class="{ active: selectedTagId === tag.id, editing: editingTagId === tag.id && tag.id !== UNTAGGED_TAG_ID }"
@click="tag.id === UNTAGGED_TAG_ID ? handleUntaggedClick() : handleTagRowClick(tag.id)"
:class="{ active: selectedTagId === tag.id, editing: editingTagId === tag.id }"
@click="handleTagRowClick(tag.id)"
>
<div class="tag-list-left">
<t-icon name="folder" size="18px" />
<!-- Untagged pseudo-tag: show translated name, no editing -->
<template v-if="tag.id === UNTAGGED_TAG_ID">
<span class="tag-name">{{ $t('knowledgeBase.untagged') || '未分类' }}</span>
</template>
<template v-else-if="editingTagId === tag.id">
<template v-if="editingTagId === tag.id">
<div class="tag-edit-input" @click.stop>
<t-input
:ref="setEditingTagInputRefByTag(tag.id)"
@@ -1069,11 +1043,7 @@ async function createNewSession(value: string): Promise<void> {
</div>
<div class="tag-list-right">
<span class="tag-count">{{ tag.knowledge_count || 0 }}</span>
<!-- Untagged pseudo-tag: no edit/delete actions -->
<template v-if="tag.id === UNTAGGED_TAG_ID">
<!-- Empty placeholder for alignment -->
</template>
<template v-else-if="editingTagId === tag.id">
<template v-if="editingTagId === tag.id">
<div class="tag-inline-actions" @click.stop>
<t-button
variant="text"
@@ -183,16 +183,12 @@
v-for="tag in filteredTags"
:key="tag.id"
class="faq-tag-item"
:class="{ active: selectedTagId === tag.id, editing: editingTagId === tag.id && tag.id !== UNTAGGED_FILTER }"
@click="tag.id === UNTAGGED_FILTER ? handleUntaggedClick() : handleTagRowClick(tag.id)"
:class="{ active: selectedTagId === tag.id, editing: editingTagId === tag.id }"
@click="handleTagRowClick(tag.id)"
>
<div class="faq-tag-left">
<t-icon name="folder" size="18px" />
<!-- Untagged pseudo-tag: show translated name, no editing -->
<template v-if="tag.id === UNTAGGED_FILTER">
<span class="tag-name">{{ $t('knowledgeBase.untagged') }}</span>
</template>
<template v-else-if="editingTagId === tag.id">
<template v-if="editingTagId === tag.id">
<div class="tag-edit-input" @click.stop>
<t-input
:ref="setEditingTagInputRefByTag(tag.id)"
@@ -210,11 +206,7 @@
</div>
<div class="faq-tag-right">
<span class="faq-tag-count">{{ tag.chunk_count || 0 }}</span>
<!-- Untagged pseudo-tag: no edit/delete actions, just a placeholder -->
<template v-if="tag.id === UNTAGGED_FILTER">
<div class="tag-more-placeholder"></div>
</template>
<template v-else-if="editingTagId === tag.id">
<template v-if="editingTagId === tag.id">
<div class="tag-inline-actions" @click.stop>
<t-button
variant="text"
@@ -1185,9 +1177,8 @@ type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: ()
const tagList = ref<any[]>([])
const tagLoading = ref(false)
const tagListRef = ref<HTMLElement | null>(null)
// Special value to represent "untagged" filter - must match backend constant
const UNTAGGED_FILTER = '__untagged__'
const selectedTagId = ref<string>(UNTAGGED_FILTER)
// Selected tag ID for filtering (empty string means show all)
const selectedTagId = ref<string>('')
const overallFAQTotal = ref(0)
const tagSearchQuery = ref('')
const TAG_PAGE_SIZE = 20
@@ -1221,16 +1212,14 @@ const tagMap = computed<Record<string, any>>(() => {
})
return map
})
// Filter out the __untagged__ pseudo-tag for tag select options (when assigning tags)
const regularTags = computed(() => tagList.value.filter((tag) => tag.id !== UNTAGGED_FILTER))
const tagDropdownOptions = computed(() => [
{ content: t('knowledgeBase.untagged') || '未分类', value: '' },
...regularTags.value.map((tag: any) => ({ content: tag.name, value: tag.id })),
])
const tagSelectOptions = computed(() => [
{ label: t('knowledgeBase.untagged') || '未分类', value: '' },
...regularTags.value.map((tag: any) => ({ label: tag.name, value: tag.id })),
])
// All tags are now regular tags (no pseudo-tag)
const regularTags = computed(() => tagList.value)
const tagDropdownOptions = computed(() =>
regularTags.value.map((tag: any) => ({ content: tag.name, value: tag.id })),
)
const tagSelectOptions = computed(() =>
regularTags.value.map((tag: any) => ({ label: tag.name, value: tag.id })),
)
const sidebarCategoryCount = computed(() => tagList.value.length)
const filteredTags = computed(() => {
const query = tagSearchQuery.value.trim().toLowerCase()
@@ -1418,17 +1407,6 @@ const handleTagRowClick = (tagId: string) => {
handleTagFilterChange(normalizedId)
}
const handleUntaggedClick = () => {
if (creatingTag.value) {
cancelCreateTag()
}
if (editingTagId.value) {
cancelEditTag()
}
if (selectedTagId.value === UNTAGGED_FILTER) return
handleTagFilterChange(UNTAGGED_FILTER)
}
const startCreateTag = () => {
if (!props.kbId) {
MessagePlugin.warning(t('knowledgeEditor.messages.missingId'))
@@ -1536,7 +1514,9 @@ const confirmDeleteTag = (tag: any) => {
await deleteKnowledgeBaseTag(props.kbId, tag.id, { force: true })
MessagePlugin.success(t('knowledgeBase.tagDeleteSuccess'))
if (selectedTagId.value === tag.id) {
handleTagFilterChange(UNTAGGED_FILTER)
// Reset to show all entries when current tag is deleted
selectedTagId.value = ''
handleTagFilterChange('')
}
await loadTags()
await loadEntries()
@@ -2253,7 +2233,7 @@ const startPolling = (taskId: string) => {
if (status === 'success') {
MessagePlugin.success(t('knowledgeEditor.faqImport.importSuccess'))
// 清除筛选条件,确保用户能看到所有新导入的数据
selectedTagId.value = UNTAGGED_FILTER
selectedTagId.value = ''
entrySearchKeyword.value = ''
overallFAQTotal.value = 0 // Reset to trigger re-fetch
await loadEntries()
@@ -2610,7 +2590,7 @@ watch(
async (newKbId) => {
currentPage = 1
hasMore.value = true
selectedTagId.value = UNTAGGED_FILTER
selectedTagId.value = ''
overallFAQTotal.value = 0 // Reset to trigger re-fetch
cancelCreateTag()
cancelEditTag()
+5 -16
View File
@@ -91,10 +91,7 @@ func (r *chunkRepository) ListPagedChunksByKnowledgeID(
baseFilter := func(db *gorm.DB) *gorm.DB {
db = db.Where("tenant_id = ? AND knowledge_id = ? AND chunk_type IN (?) AND status in (?)",
tenantID, knowledgeID, chunkType, []int{int(types.ChunkStatusIndexed), int(types.ChunkStatusDefault)})
if tagID == types.UntaggedTagID {
// Special value to filter entries without a tag
db = db.Where("tag_id = ''")
} else if tagID != "" {
if tagID != "" {
db = db.Where("tag_id = ?", tagID)
}
if keyword != "" {
@@ -640,9 +637,7 @@ func (r *chunkRepository) UpdateChunkFieldsByTagID(
Select("id").
Where("tenant_id = ? AND knowledge_base_id = ? AND chunk_type = ?",
tenantID, kbID, types.ChunkTypeFAQ)
if tagID == "" || tagID == types.UntaggedTagID {
query = query.Where("(tag_id = '' OR tag_id IS NULL)")
} else {
if tagID != "" {
query = query.Where("tag_id = ?", tagID)
}
@@ -669,22 +664,16 @@ func (r *chunkRepository) UpdateChunkFieldsByTagID(
updates["is_enabled"] = *isEnabled
}
// Handle newTagID update (__untagged__ is stored as empty string)
// Handle newTagID update
if newTagID != nil {
if *newTagID == types.UntaggedTagID || *newTagID == "" {
updates["tag_id"] = ""
} else {
updates["tag_id"] = *newTagID
}
updates["tag_id"] = *newTagID
}
query := r.db.WithContext(ctx).Model(&types.Chunk{}).
Where("tenant_id = ? AND knowledge_base_id = ? AND chunk_type = ?",
tenantID, kbID, types.ChunkTypeFAQ)
if tagID == "" || tagID == types.UntaggedTagID {
query = query.Where("(tag_id = '' OR tag_id IS NULL)")
} else {
if tagID != "" {
query = query.Where("tag_id = ?", tagID)
}
+2 -12
View File
@@ -72,12 +72,7 @@ func (r *knowledgeRepository) ListPagedKnowledgeByKnowledgeBaseID(
query := r.db.WithContext(ctx).Model(&types.Knowledge{}).
Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID)
if tagID != "" {
if tagID == types.UntaggedTagID {
// Special value to filter entries without a tag
query = query.Where("tag_id = '' OR tag_id IS NULL")
} else {
query = query.Where("tag_id = ?", tagID)
}
query = query.Where("tag_id = ?", tagID)
}
if keyword != "" {
query = query.Where("file_name LIKE ?", "%"+keyword+"%")
@@ -101,12 +96,7 @@ func (r *knowledgeRepository) ListPagedKnowledgeByKnowledgeBaseID(
dataQuery := r.db.WithContext(ctx).
Where("tenant_id = ? AND knowledge_base_id = ?", tenantID, kbID)
if tagID != "" {
if tagID == types.UntaggedTagID {
// Special value to filter entries without a tag
dataQuery = dataQuery.Where("tag_id = '' OR tag_id IS NULL")
} else {
dataQuery = dataQuery.Where("tag_id = ?", tagID)
}
dataQuery = dataQuery.Where("tag_id = ?", tagID)
}
if keyword != "" {
dataQuery = dataQuery.Where("file_name LIKE ?", "%"+keyword+"%")
-21
View File
@@ -136,27 +136,6 @@ func (r *knowledgeTagRepository) CountReferences(
return
}
// CountUntaggedReferences returns the number of knowledges and chunks without a tag
func (r *knowledgeTagRepository) CountUntaggedReferences(
ctx context.Context,
tenantID uint64,
kbID string,
) (knowledgeCount int64, chunkCount int64, err error) {
if err = r.db.WithContext(ctx).
Model(&types.Knowledge{}).
Where("tenant_id = ? AND knowledge_base_id = ? AND (tag_id = '' OR tag_id IS NULL)", tenantID, kbID).
Count(&knowledgeCount).Error; err != nil {
return
}
if err = r.db.WithContext(ctx).
Model(&types.Chunk{}).
Where("tenant_id = ? AND knowledge_base_id = ? AND (tag_id = '' OR tag_id IS NULL)", tenantID, kbID).
Count(&chunkCount).Error; err != nil {
return
}
return
}
// DeleteUnusedTags deletes tags that are not referenced by any knowledge or chunk.
// Returns the number of deleted tags.
func (r *knowledgeTagRepository) DeleteUnusedTags(ctx context.Context, tenantID uint64, kbID string) (int64, error) {
+8 -3
View File
@@ -4590,6 +4590,7 @@ func (s *knowledgeService) checkFAQQuestionDuplicate(
}
// resolveTagID resolves tag ID from payload, prioritizing tag_id over tag_name
// If no tag is specified, creates or finds the "未分类" tag
func (s *knowledgeService) resolveTagID(ctx context.Context, kbID string, payload *types.FAQEntryPayload) (string, error) {
// 如果提供了 tag_id,优先使用 tag_id
if payload.TagID != "" {
@@ -4597,7 +4598,7 @@ func (s *knowledgeService) resolveTagID(ctx context.Context, kbID string, payloa
}
// 如果提供了 tag_name,查找或创建标签
if payload.TagName != "" && payload.TagName != "未分类" {
if payload.TagName != "" {
tag, err := s.tagService.FindOrCreateTagByName(ctx, kbID, payload.TagName)
if err != nil {
return "", fmt.Errorf("failed to resolve tag by name '%s': %w", payload.TagName, err)
@@ -4605,8 +4606,12 @@ func (s *knowledgeService) resolveTagID(ctx context.Context, kbID string, payloa
return tag.ID, nil
}
// 都没有提供,返回空字符串
return "", nil
// 都没有提供,使用"未分类"标签
tag, err := s.tagService.FindOrCreateTagByName(ctx, kbID, types.UntaggedTagName)
if err != nil {
return "", fmt.Errorf("failed to get or create default untagged tag: %w", err)
}
return tag.ID, nil
}
func sanitizeFAQEntryPayload(payload *types.FAQEntryPayload) (*types.FAQChunkMetadata, error) {
+1 -27
View File
@@ -72,28 +72,7 @@ func (s *knowledgeTagService) ListTags(
return nil, err
}
results := make([]*types.KnowledgeTagWithStats, 0, len(tags)+1)
// Add untagged pseudo-tag at the beginning (only on first page and when no keyword filter)
if page.Page <= 1 && keyword == "" {
untaggedKCount, untaggedCCount, err := s.repo.CountUntaggedReferences(ctx, tenantID, kbID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"kb_id": kbID,
})
return nil, err
}
results = append(results, &types.KnowledgeTagWithStats{
KnowledgeTag: types.KnowledgeTag{
ID: types.UntaggedTagID,
TenantID: tenantID,
KnowledgeBaseID: kbID,
Name: "未分类",
},
KnowledgeCount: untaggedKCount,
ChunkCount: untaggedCCount,
})
}
results := make([]*types.KnowledgeTagWithStats, 0, len(tags))
for _, tag := range tags {
if tag == nil {
@@ -114,11 +93,6 @@ func (s *knowledgeTagService) ListTags(
})
}
// Adjust total to include the untagged pseudo-tag
if keyword == "" {
total++
}
return types.NewPageResult(total, page, results), nil
}
+3 -3
View File
@@ -230,8 +230,8 @@ type FAQSearchRequest struct {
SecondPriorityTagIDs []string `json:"second_priority_tag_ids"` // 第二优先级标签ID列表,限定命中范围,优先级低于第一优先级
}
// UntaggedTagID is the special tag ID representing uncategorized entries
const UntaggedTagID = "__untagged__"
// UntaggedTagName is the default tag name for entries without a tag
const UntaggedTagName = "未分类"
// FAQEntryFieldsUpdate 单个FAQ条目的字段更新
type FAQEntryFieldsUpdate struct {
@@ -248,7 +248,7 @@ type FAQEntryFieldsUpdate struct {
type FAQEntryFieldsBatchUpdate struct {
// ByID 按条目ID更新,key为条目ID
ByID map[string]FAQEntryFieldsUpdate `json:"by_id,omitempty"`
// ByTag 按Tag批量更新,key为TagID(__untagged__表示未分类)
// ByTag 按Tag批量更新,key为TagID
ByTag map[string]FAQEntryFieldsUpdate `json:"by_tag,omitempty"`
// ExcludeIDs 在ByTag操作中需要排除的ID列表
ExcludeIDs []string `json:"exclude_ids,omitempty"`
-6
View File
@@ -48,12 +48,6 @@ type KnowledgeTagRepository interface {
kbID string,
tagID string,
) (knowledgeCount int64, chunkCount int64, err error)
// CountUntaggedReferences returns number of knowledges and chunks without a tag.
CountUntaggedReferences(
ctx context.Context,
tenantID uint64,
kbID string,
) (knowledgeCount int64, chunkCount int64, err error)
// DeleteUnusedTags deletes tags that are not referenced by any knowledge or chunk.
DeleteUnusedTags(ctx context.Context, tenantID uint64, kbID string) (int64, error)
}
@@ -0,0 +1,50 @@
-- Rollback: This migration cannot be fully rolled back as we don't know which entries
-- were originally untagged. We can only clear the tag_id for entries that reference
-- a "未分类" tag, but this may affect entries that were intentionally tagged.
-- WARNING: This rollback is destructive and should only be used if absolutely necessary.
-- It will set tag_id to empty string for all FAQ entries that reference "未分类" tags.
DO $$
DECLARE
kb_record RECORD;
untagged_tag_id VARCHAR(36);
updated_chunks INT;
BEGIN
RAISE NOTICE '[Migration 000008 Rollback] WARNING: This rollback will clear tag_id for all FAQ entries referencing "未分类" tags';
-- Find all "未分类" tags
FOR kb_record IN
SELECT id, tenant_id, knowledge_base_id
FROM knowledge_tags
WHERE name = '未分类'
LOOP
untagged_tag_id := kb_record.id;
-- Clear tag_id for chunks referencing this tag
UPDATE chunks
SET tag_id = '', updated_at = NOW()
WHERE tag_id = untagged_tag_id
AND chunk_type = 'faq';
GET DIAGNOSTICS updated_chunks = ROW_COUNT;
RAISE NOTICE '[Migration 000008 Rollback] Cleared tag_id for % chunks referencing tag %',
updated_chunks, untagged_tag_id;
-- Clear tag_id in embeddings if column exists
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'embeddings' AND column_name = 'tag_id'
) THEN
UPDATE embeddings
SET tag_id = ''
WHERE tag_id = untagged_tag_id;
END IF;
-- Delete the "未分类" tag
DELETE FROM knowledge_tags WHERE id = untagged_tag_id;
RAISE NOTICE '[Migration 000008 Rollback] Deleted "未分类" tag: %', untagged_tag_id;
END LOOP;
RAISE NOTICE '[Migration 000008 Rollback] Completed rollback';
END $$;
@@ -0,0 +1,73 @@
-- Migration: Create "未分类" tag for each knowledge base that has untagged FAQ entries
-- and update those entries to reference the new tag
DO $$
DECLARE
kb_record RECORD;
new_tag_id VARCHAR(36);
updated_chunks INT;
updated_embeddings INT;
BEGIN
-- Find all knowledge bases that have FAQ chunks with empty or NULL tag_id
FOR kb_record IN
SELECT DISTINCT c.tenant_id, c.knowledge_base_id
FROM chunks c
WHERE c.chunk_type = 'faq'
AND (c.tag_id = '' OR c.tag_id IS NULL)
LOOP
-- Check if "未分类" tag already exists for this knowledge base
SELECT id INTO new_tag_id
FROM knowledge_tags
WHERE tenant_id = kb_record.tenant_id
AND knowledge_base_id = kb_record.knowledge_base_id
AND name = '未分类'
LIMIT 1;
-- If not exists, create the tag
IF new_tag_id IS NULL THEN
new_tag_id := gen_random_uuid()::VARCHAR(36);
INSERT INTO knowledge_tags (id, tenant_id, knowledge_base_id, name, color, sort_order, created_at, updated_at)
VALUES (new_tag_id, kb_record.tenant_id, kb_record.knowledge_base_id, '未分类', '', 0, NOW(), NOW());
RAISE NOTICE '[Migration 000008] Created "未分类" tag (id: %) for tenant_id: %, kb_id: %',
new_tag_id, kb_record.tenant_id, kb_record.knowledge_base_id;
ELSE
RAISE NOTICE '[Migration 000008] "未分类" tag already exists (id: %) for tenant_id: %, kb_id: %',
new_tag_id, kb_record.tenant_id, kb_record.knowledge_base_id;
END IF;
-- Update chunks with empty tag_id to use the new tag
UPDATE chunks
SET tag_id = new_tag_id, updated_at = NOW()
WHERE tenant_id = kb_record.tenant_id
AND knowledge_base_id = kb_record.knowledge_base_id
AND chunk_type = 'faq'
AND (tag_id = '' OR tag_id IS NULL);
GET DIAGNOSTICS updated_chunks = ROW_COUNT;
RAISE NOTICE '[Migration 000008] Updated % chunks for tenant_id: %, kb_id: %',
updated_chunks, kb_record.tenant_id, kb_record.knowledge_base_id;
-- Update embeddings with empty tag_id (if embeddings table exists and has tag_id column)
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'embeddings' AND column_name = 'tag_id'
) THEN
UPDATE embeddings
SET tag_id = new_tag_id
WHERE knowledge_base_id = kb_record.knowledge_base_id
AND (tag_id = '' OR tag_id IS NULL)
AND chunk_id IN (
SELECT id FROM chunks
WHERE tenant_id = kb_record.tenant_id
AND knowledge_base_id = kb_record.knowledge_base_id
AND chunk_type = 'faq'
);
GET DIAGNOSTICS updated_embeddings = ROW_COUNT;
RAISE NOTICE '[Migration 000008] Updated % embeddings for kb_id: %',
updated_embeddings, kb_record.knowledge_base_id;
END IF;
END LOOP;
RAISE NOTICE '[Migration 000008] Completed migration of untagged FAQ entries';
END $$;