feat: 将未分类FAQ条目转为"未分类"标签
This commit is contained in:
@@ -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}; \
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1238,6 +1238,7 @@ export default {
|
||||
questionIndexModeDescription: "병합 인덱스: 표준 질문과 유사 질문을 병합 인덱싱; 개별 인덱스: 표준 질문과 각 유사 질문을 독립적으로 인덱싱하여 더 정확하게 검색하지만 더 많은 저장 공간이 필요합니다",
|
||||
entryGuide: "FAQ 항목은 표준 질문, 유사 질문, 반례 및 여러 답변으로 구성됩니다. 지식베이스 세부 정보에서 일괄 가져오기 및 편집할 수 있습니다.",
|
||||
tagDesc: "FAQ 항목에 분류 선택",
|
||||
tagPlaceholder: "분류를 선택하세요",
|
||||
modes: {
|
||||
questionOnly: "표준 질문/유사 질문만",
|
||||
questionAnswer: "표준 질문 + 답변",
|
||||
|
||||
@@ -1056,6 +1056,7 @@ export default {
|
||||
questionIndexModeDescription: 'Объединенная: стандартные и похожие вопросы индексируются вместе. Раздельная: каждый вопрос индексируется независимо для более точного поиска, но требует больше места.',
|
||||
entryGuide: 'Каждый FAQ включает основной вопрос, похожие вопросы, негативные примеры и несколько ответов. Управляйте ими в деталях FAQ-базы.',
|
||||
tagDesc: 'Выберите категорию для записей FAQ',
|
||||
tagPlaceholder: 'Пожалуйста, выберите категорию',
|
||||
modes: {
|
||||
questionOnly: 'Только вопросы',
|
||||
questionAnswer: 'Вопрос + ответ',
|
||||
|
||||
@@ -1463,6 +1463,7 @@ export default {
|
||||
questionIndexModeDescription: "合并索引:标准问和相似问合并索引;分别索引:标准问和每个相似问独立索引,检索更精确但需要更多存储",
|
||||
entryGuide: "FAQ 条目由标准问、相似问、反例和多个答案组成,可在知识库详情中批量导入、编辑。",
|
||||
tagDesc: "为 FAQ 条目选择分类",
|
||||
tagPlaceholder: "请选择分类",
|
||||
modes: {
|
||||
questionOnly: "仅标准问/相似问",
|
||||
questionAnswer: "标准问 + 答案",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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+"%")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 $$;
|
||||
Reference in New Issue
Block a user