feat: 为 FAQ 和标签引入 seq_id 支持

This commit is contained in:
wizardchen
2026-01-12 14:30:37 +08:00
parent 2e9793db8e
commit 14d7d3d995
21 changed files with 716 additions and 250 deletions
+1
View File
@@ -15,6 +15,7 @@ import (
// Chunks are the basic units of storage and indexing in the knowledge base
type Chunk struct {
ID string `json:"id"` // Unique identifier of the chunk
SeqID int64 `json:"seq_id"` // Auto-increment integer ID for external API usage
KnowledgeID string `json:"knowledge_id"` // Identifier of the parent knowledge
KnowledgeBaseID string `json:"knowledge_base_id"` // ID of the knowledge base
TenantID uint64 `json:"tenant_id"` // Tenant ID
+39 -33
View File
@@ -12,11 +12,11 @@ import (
// FAQEntry represents a FAQ item stored under a knowledge base.
type FAQEntry struct {
ID string `json:"id"`
ID int64 `json:"id"`
ChunkID string `json:"chunk_id"`
KnowledgeID string `json:"knowledge_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
TagID string `json:"tag_id"`
TagID int64 `json:"tag_id"`
TagName string `json:"tag_name"`
IsEnabled bool `json:"is_enabled"`
IsRecommended bool `json:"is_recommended"`
@@ -35,12 +35,14 @@ type FAQEntry struct {
// FAQEntryPayload is used to create or update a FAQ entry.
type FAQEntryPayload struct {
// ID is optional, used for data migration to specify seq_id (must be less than auto-increment start value 100000000)
ID *int64 `json:"id,omitempty"`
StandardQuestion string `json:"standard_question"`
SimilarQuestions []string `json:"similar_questions,omitempty"`
NegativeQuestions []string `json:"negative_questions,omitempty"`
Answers []string `json:"answers"`
AnswerStrategy *string `json:"answer_strategy,omitempty"`
TagID string `json:"tag_id,omitempty"`
TagID int64 `json:"tag_id,omitempty"`
TagName string `json:"tag_name,omitempty"`
IsEnabled *bool `json:"is_enabled,omitempty"`
IsRecommended *bool `json:"is_recommended,omitempty"`
@@ -57,9 +59,9 @@ type FAQBatchUpsertPayload struct {
// FAQEntryFieldsUpdate represents the fields that can be updated for a single FAQ entry.
type FAQEntryFieldsUpdate struct {
IsEnabled *bool `json:"is_enabled,omitempty"`
IsRecommended *bool `json:"is_recommended,omitempty"`
TagID *string `json:"tag_id,omitempty"`
IsEnabled *bool `json:"is_enabled,omitempty"`
IsRecommended *bool `json:"is_recommended,omitempty"`
TagID *int64 `json:"tag_id,omitempty"`
}
// FAQEntryFieldsBatchRequest updates multiple fields for FAQ entries in bulk.
@@ -67,31 +69,33 @@ type FAQEntryFieldsUpdate struct {
// 1. By entry ID: use ByID field
// 2. By Tag: use ByTag field to apply the same update to all entries under a tag
type FAQEntryFieldsBatchRequest struct {
// ByID updates by entry ID, key is entry ID
ByID map[string]FAQEntryFieldsUpdate `json:"by_id,omitempty"`
// ByTag updates all entries under a tag, key is tag ID (empty string for uncategorized)
ByTag map[string]FAQEntryFieldsUpdate `json:"by_tag,omitempty"`
// ExcludeIDs IDs to exclude from the ByTag update
ExcludeIDs []string `json:"exclude_ids,omitempty"`
// ByID updates by entry ID (seq_id), key is entry seq_id
ByID map[int64]FAQEntryFieldsUpdate `json:"by_id,omitempty"`
// ByTag updates all entries under a tag, key is tag seq_id (0 for uncategorized)
ByTag map[int64]FAQEntryFieldsUpdate `json:"by_tag,omitempty"`
// ExcludeIDs IDs (seq_id) to exclude from the ByTag update
ExcludeIDs []int64 `json:"exclude_ids,omitempty"`
}
// FAQEntryTagBatchRequest updates tags in bulk.
// key: entry seq_id, value: tag seq_id (nil to remove tag)
type FAQEntryTagBatchRequest struct {
Updates map[string]*string `json:"updates"`
Updates map[int64]*int64 `json:"updates"`
}
// FAQDeleteRequest deletes entries in bulk.
type FAQDeleteRequest struct {
IDs []string `json:"ids"`
IDs []int64 `json:"ids"`
}
// FAQSearchRequest represents the hybrid FAQ search request.
type FAQSearchRequest struct {
QueryText string `json:"query_text"`
VectorThreshold float64 `json:"vector_threshold"`
MatchCount int `json:"match_count"`
FirstPriorityTagIDs []string `json:"first_priority_tag_ids"` // First priority tag IDs, highest priority
SecondPriorityTagIDs []string `json:"second_priority_tag_ids"` // Second priority tag IDs, lower than first
QueryText string `json:"query_text"`
VectorThreshold float64 `json:"vector_threshold"`
MatchCount int `json:"match_count"`
FirstPriorityTagIDs []int64 `json:"first_priority_tag_ids"` // First priority tag seq_ids, highest priority
SecondPriorityTagIDs []int64 `json:"second_priority_tag_ids"` // Second priority tag seq_ids, lower than first
OnlyRecommended bool `json:"only_recommended"` // Only return recommended entries
}
// FAQEntriesPage contains paginated FAQ results.
@@ -146,10 +150,11 @@ type faqSimpleResponse struct {
}
// ListFAQEntries returns paginated FAQ entries under a knowledge base.
// tagSeqID: filter by tag seq_id (0 means no filter)
// searchField: specifies which field to search in ("standard_question", "similar_questions", "answers", "" for all)
// sortOrder: "asc" for time ascending (updated_at ASC), default is time descending (updated_at DESC)
func (c *Client) ListFAQEntries(ctx context.Context,
knowledgeBaseID string, page, pageSize int, tagID string, keyword string, searchField string, sortOrder string,
knowledgeBaseID string, page, pageSize int, tagSeqID int64, keyword string, searchField string, sortOrder string,
) (*FAQEntriesPage, error) {
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries", knowledgeBaseID)
query := url.Values{}
@@ -159,8 +164,8 @@ func (c *Client) ListFAQEntries(ctx context.Context,
if pageSize > 0 {
query.Add("page_size", strconv.Itoa(pageSize))
}
if tagID != "" {
query.Add("tag_id", tagID)
if tagSeqID != 0 {
query.Add("tag_id", strconv.FormatInt(tagSeqID, 10))
}
if keyword != "" {
query.Add("keyword", keyword)
@@ -224,11 +229,11 @@ func (c *Client) CreateFAQEntry(ctx context.Context,
return response.Data, nil
}
// GetFAQEntry retrieves a single FAQ entry by ID.
// GetFAQEntry retrieves a single FAQ entry by seq_id.
func (c *Client) GetFAQEntry(ctx context.Context,
knowledgeBaseID, entryID string,
knowledgeBaseID string, entrySeqID int64,
) (*FAQEntry, error) {
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/%s", knowledgeBaseID, entryID)
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/%d", knowledgeBaseID, entrySeqID)
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
if err != nil {
return nil, err
@@ -243,9 +248,9 @@ func (c *Client) GetFAQEntry(ctx context.Context,
// UpdateFAQEntry updates a single FAQ entry.
func (c *Client) UpdateFAQEntry(ctx context.Context,
knowledgeBaseID, entryID string, payload *FAQEntryPayload,
knowledgeBaseID string, entrySeqID int64, payload *FAQEntryPayload,
) (*FAQEntry, error) {
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/%s", knowledgeBaseID, entryID)
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/%d", knowledgeBaseID, entrySeqID)
resp, err := c.doRequest(ctx, http.MethodPut, path, payload, nil)
if err != nil {
return nil, err
@@ -261,10 +266,10 @@ func (c *Client) UpdateFAQEntry(ctx context.Context,
// UpdateFAQEntryFieldsBatch updates multiple fields for FAQ entries in bulk.
// Supports updating is_enabled, is_recommended, tag_id in a single call.
// Supports two modes:
// - byID: update by entry ID, key is entry ID
// - byTag: update all entries under a tag, key is tag ID (empty string for uncategorized)
// - byID: update by entry seq_id, key is entry seq_id
// - byTag: update all entries under a tag, key is tag seq_id (0 for uncategorized)
func (c *Client) UpdateFAQEntryFieldsBatch(ctx context.Context,
knowledgeBaseID string, byID map[string]FAQEntryFieldsUpdate, byTag map[string]FAQEntryFieldsUpdate, excludeIDs []string,
knowledgeBaseID string, byID map[int64]FAQEntryFieldsUpdate, byTag map[int64]FAQEntryFieldsUpdate, excludeIDs []int64,
) error {
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/fields", knowledgeBaseID)
resp, err := c.doRequest(ctx, http.MethodPut, path, &FAQEntryFieldsBatchRequest{ByID: byID, ByTag: byTag, ExcludeIDs: excludeIDs}, nil)
@@ -277,8 +282,9 @@ func (c *Client) UpdateFAQEntryFieldsBatch(ctx context.Context,
}
// UpdateFAQEntryTagBatch updates FAQ entry tags in bulk.
// key: entry seq_id, value: tag seq_id (nil to remove tag)
func (c *Client) UpdateFAQEntryTagBatch(ctx context.Context,
knowledgeBaseID string, updates map[string]*string,
knowledgeBaseID string, updates map[int64]*int64,
) error {
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/tags", knowledgeBaseID)
resp, err := c.doRequest(ctx, http.MethodPut, path, &FAQEntryTagBatchRequest{Updates: updates}, nil)
@@ -290,9 +296,9 @@ func (c *Client) UpdateFAQEntryTagBatch(ctx context.Context,
return parseResponse(resp, &response)
}
// DeleteFAQEntries deletes FAQ entries in bulk.
// DeleteFAQEntries deletes FAQ entries in bulk by seq_id.
func (c *Client) DeleteFAQEntries(ctx context.Context,
knowledgeBaseID string, ids []string,
knowledgeBaseID string, ids []int64,
) error {
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries", knowledgeBaseID)
resp, err := c.doRequest(ctx, http.MethodDelete, path, &FAQDeleteRequest{IDs: ids}, nil)
+19 -2
View File
@@ -12,6 +12,7 @@ import (
// Tag represents a knowledge base tag.
type Tag struct {
ID string `json:"id"`
SeqID int64 `json:"seq_id"`
TenantID uint64 `json:"tenant_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
Name string `json:"name"`
@@ -121,6 +122,7 @@ func (c *Client) CreateTag(ctx context.Context,
}
// UpdateTag updates an existing tag.
// tagID can be either UUID or seq_id (as string).
func (c *Client) UpdateTag(ctx context.Context,
knowledgeBaseID, tagID string, payload *UpdateTagPayload,
) (*Tag, error) {
@@ -137,12 +139,20 @@ func (c *Client) UpdateTag(ctx context.Context,
return response.Data, nil
}
// UpdateTagBySeqID updates an existing tag by seq_id.
func (c *Client) UpdateTagBySeqID(ctx context.Context,
knowledgeBaseID string, tagSeqID int64, payload *UpdateTagPayload,
) (*Tag, error) {
return c.UpdateTag(ctx, knowledgeBaseID, strconv.FormatInt(tagSeqID, 10), payload)
}
// DeleteTag deletes a tag.
// tagID can be either UUID or seq_id (as string).
// Set force to true to delete even if the tag is referenced.
// Set contentOnly to true to only delete the content under the tag but keep the tag itself.
// excludeIDs: IDs of chunks to exclude from deletion.
// excludeIDs: seq_ids of chunks to exclude from deletion.
func (c *Client) DeleteTag(ctx context.Context,
knowledgeBaseID, tagID string, force bool, contentOnly bool, excludeIDs []string,
knowledgeBaseID, tagID string, force bool, contentOnly bool, excludeIDs []int64,
) error {
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/tags/%s", knowledgeBaseID, tagID)
query := url.Values{}
@@ -168,3 +178,10 @@ func (c *Client) DeleteTag(ctx context.Context,
var response tagSimpleResponse
return parseResponse(resp, &response)
}
// DeleteTagBySeqID deletes a tag by seq_id.
func (c *Client) DeleteTagBySeqID(ctx context.Context,
knowledgeBaseID string, tagSeqID int64, force bool, contentOnly bool, excludeIDs []int64,
) error {
return c.DeleteTag(ctx, knowledgeBaseID, strconv.FormatInt(tagSeqID, 10), force, contentOnly, excludeIDs)
}
+7 -6
View File
@@ -138,7 +138,7 @@ export function updateKnowledgeTagBatch(data: { updates: Record<string, string |
return put(`/api/v1/knowledge/tags`, data);
}
export function updateFAQEntryTagBatch(kbId: string, data: { updates: Record<string, string | null> }) {
export function updateFAQEntryTagBatch(kbId: string, data: { updates: Record<number, number | null> }) {
return put(`/api/v1/knowledge-bases/${kbId}/faq/entries/tags`, data);
}
@@ -169,7 +169,7 @@ export function createFAQEntry(kbId: string, data: any) {
return post(`/api/v1/knowledge-bases/${kbId}/faq/entry`, data);
}
export function updateFAQEntry(kbId: string, entryId: string, data: any) {
export function updateFAQEntry(kbId: string, entryId: number, data: any) {
return put(`/api/v1/knowledge-bases/${kbId}/faq/entries/${entryId}`, data);
}
@@ -180,19 +180,20 @@ export function updateFAQEntry(kbId: string, entryId: string, data: any) {
export interface FAQEntryFieldsUpdate {
is_enabled?: boolean
is_recommended?: boolean
tag_id?: string | null
tag_id?: number | null
}
export interface FAQEntryFieldsBatchRequest {
by_id?: Record<string, FAQEntryFieldsUpdate>
by_tag?: Record<string, FAQEntryFieldsUpdate>
by_id?: Record<number, FAQEntryFieldsUpdate>
by_tag?: Record<number, FAQEntryFieldsUpdate>
exclude_ids?: number[]
}
export function updateFAQEntryFieldsBatch(kbId: string, data: FAQEntryFieldsBatchRequest) {
return put(`/api/v1/knowledge-bases/${kbId}/faq/entries/fields`, data);
}
export function deleteFAQEntries(kbId: string, ids: string[]) {
export function deleteFAQEntries(kbId: string, ids: number[]) {
return del(`/api/v1/knowledge-bases/${kbId}/faq/entries`, { ids });
}
@@ -1324,11 +1324,11 @@ import FAQTagTooltip from '@/components/FAQTagTooltip.vue'
import { useUIStore } from '@/stores/ui'
interface FAQEntry {
id: string
id: number
chunk_id: string
knowledge_id: string
knowledge_base_id: string
tag_id?: string
tag_id?: number
is_enabled: boolean
is_recommended: boolean
standard_question: string
@@ -1350,7 +1350,7 @@ interface FAQEntryPayload {
similar_questions: string[]
negative_questions: string[]
answers: string[]
tag_id?: string
tag_id?: number
tag_name?: string
is_enabled?: boolean
is_recommended?: boolean
@@ -1367,9 +1367,9 @@ const uiStore = useUIStore()
const loading = ref(false)
const loadingMore = ref(false)
const entries = ref<FAQEntry[]>([])
const entryStatusLoading = reactive<Record<string, boolean>>({})
const entryRecommendedLoading = reactive<Record<string, boolean>>({})
const selectedRowKeys = ref<string[]>([])
const entryStatusLoading = reactive<Record<number, boolean>>({})
const entryRecommendedLoading = reactive<Record<number, boolean>>({})
const selectedRowKeys = ref<number[]>([])
const scrollContainer = ref<HTMLElement | null>(null)
const cardListRef = ref<HTMLElement | null>(null)
const hasMore = ref(true)
@@ -1410,7 +1410,16 @@ const newTagName = ref('')
const editingTagId = ref<string | null>(null)
const editingTagName = ref('')
const editingTagSubmitting = ref(false)
const tagMap = computed<Record<string, any>>(() => {
// tagMap uses seq_id as key for looking up by entry.tag_id
const tagMap = computed<Record<number, any>>(() => {
const map: Record<number, any> = {}
tagList.value.forEach((tag) => {
map[tag.seq_id] = tag
})
return map
})
// tagMapById uses UUID as key for editing operations
const tagMapById = computed<Record<string, any>>(() => {
const map: Record<string, any> = {}
tagList.value.forEach((tag) => {
map[tag.id] = tag
@@ -1420,10 +1429,10 @@ const tagMap = computed<Record<string, any>>(() => {
// 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 })),
regularTags.value.map((tag: any) => ({ content: tag.name, value: String(tag.seq_id) })),
)
const tagSelectOptions = computed(() =>
regularTags.value.map((tag: any) => ({ label: tag.name, value: tag.id })),
regularTags.value.map((tag: any) => ({ label: tag.name, value: tag.seq_id })),
)
const sidebarCategoryCount = computed(() => tagList.value.length)
const filteredTags = computed(() => {
@@ -1476,13 +1485,13 @@ const loadKnowledgeList = async () => {
const editorVisible = ref(false)
const editorMode = ref<'create' | 'edit'>('create')
const currentEntryId = ref<string | null>(null)
const currentEntryId = ref<number | null>(null)
const editorForm = reactive<FAQEntryPayload>({
standard_question: '',
similar_questions: [],
negative_questions: [],
answers: [],
tag_id: '',
tag_id: undefined,
})
const editorFormRef = ref<FormInstanceFunctions>()
const savingEntry = ref(false)
@@ -1603,7 +1612,7 @@ const loadTags = async (reset = false) => {
}
}
const getTagName = (tagId?: string) => {
const getTagName = (tagId?: number) => {
if (!tagId) return t('knowledgeBase.untagged') || '未分类'
return tagMap.value[tagId]?.name || (t('knowledgeBase.untagged') || '未分类')
}
@@ -1695,7 +1704,7 @@ const submitEditTag = async () => {
MessagePlugin.warning(t('knowledgeBase.tagNameRequired'))
return
}
if (name === tagMap.value[editingTagId.value]?.name) {
if (name === tagMapById.value[editingTagId.value]?.name) {
cancelEditTag()
return
}
@@ -1747,16 +1756,16 @@ const confirmDeleteTag = (tag: any) => {
})
}
const handleEntryTagChange = async (entryId: string, value?: string) => {
const handleEntryTagChange = async (entryId: number, value?: string) => {
if (!props.kbId) return
const targetEntry = entries.value.find((item) => item.id === entryId)
const previousTagId = targetEntry ? targetEntry.tag_id : ''
const normalizedValue = value ?? ''
const previousTagId = targetEntry ? targetEntry.tag_id : undefined
const normalizedValue = value ? Number(value) : null
if (normalizedValue === previousTagId) {
return
}
try {
await updateFAQEntryTagBatch(props.kbId, { updates: { [entryId]: normalizedValue || null } })
await updateFAQEntryTagBatch(props.kbId, { updates: { [entryId]: normalizedValue } })
MessagePlugin.success(t('knowledgeEditor.messages.updateSuccess'))
await loadEntries()
await loadTags()
@@ -1902,7 +1911,7 @@ const loadEntries = async (append = false) => {
entries.value = []
selectedRowKeys.value = []
Object.keys(entryStatusLoading).forEach((key) => {
delete entryStatusLoading[key]
delete entryStatusLoading[Number(key)]
})
}
@@ -1933,7 +1942,6 @@ const loadEntries = async (append = false) => {
similarCollapsed: true, // 相似问默认折叠
negativeCollapsed: true, // 反例默认折叠
answersCollapsed: true, // 答案默认折叠
tag_id: entry.tag_id ? String(entry.tag_id) : '',
is_enabled: entry.is_enabled !== false,
}))
@@ -1993,7 +2001,7 @@ const checkAndLoadMore = () => {
}
}
const handleCardSelect = (entryId: string, checked: boolean) => {
const handleCardSelect = (entryId: number, checked: boolean) => {
if (checked) {
if (!selectedRowKeys.value.includes(entryId)) {
selectedRowKeys.value.push(entryId)
@@ -2011,7 +2019,7 @@ const resetEditorForm = () => {
editorForm.similar_questions = []
editorForm.negative_questions = []
editorForm.answers = []
editorForm.tag_id = ''
editorForm.tag_id = undefined
answerInput.value = ''
similarInput.value = ''
negativeInput.value = ''
@@ -2025,7 +2033,7 @@ const openEditor = (entry?: FAQEntry) => {
editorForm.similar_questions = [...(entry.similar_questions || [])]
editorForm.negative_questions = [...(entry.negative_questions || [])]
editorForm.answers = [...(entry.answers || [])]
editorForm.tag_id = entry.tag_id || ''
editorForm.tag_id = entry.tag_id || undefined
} else {
editorMode.value = 'create'
currentEntryId.value = null
@@ -2100,7 +2108,7 @@ const handleSubmitEntry = async () => {
similar_questions: [...editorForm.similar_questions],
negative_questions: [...editorForm.negative_questions],
answers: [...editorForm.answers],
tag_id: editorForm.tag_id || '',
tag_id: editorForm.tag_id || undefined,
}
if (editorMode.value === 'create') {
await createFAQEntry(props.kbId, payload)
@@ -2143,9 +2151,9 @@ const openBatchTagDialog = () => {
const handleBatchTag = async () => {
if (!selectedRowKeys.value.length || !props.kbId) return
try {
const updates: Record<string, string | null> = {}
const updates: Record<number, number | null> = {}
selectedRowKeys.value.forEach(id => {
updates[id] = batchTagValue.value || null
updates[id] = batchTagValue.value ? Number(batchTagValue.value) : null
})
await updateFAQEntryTagBatch(props.kbId, { updates })
MessagePlugin.success(t('knowledgeEditor.messages.updateSuccess'))
@@ -2161,7 +2169,7 @@ const handleBatchTag = async () => {
const handleBatchStatusChange = async (isEnabled: boolean) => {
if (!selectedRowKeys.value.length || !props.kbId) return
try {
const by_id: Record<string, { is_enabled: boolean }> = {}
const by_id: Record<number, { is_enabled: boolean }> = {}
selectedRowKeys.value.forEach(id => {
by_id[id] = { is_enabled: isEnabled }
})
@@ -2177,7 +2185,7 @@ const handleBatchStatusChange = async (isEnabled: boolean) => {
const handleBatchRecommendedChange = async (isRecommended: boolean) => {
if (!selectedRowKeys.value.length || !props.kbId) return
try {
const by_id: Record<string, { is_recommended: boolean }> = {}
const by_id: Record<number, { is_recommended: boolean }> = {}
selectedRowKeys.value.forEach(id => {
by_id[id] = { is_recommended: isRecommended }
})
@@ -2302,7 +2310,7 @@ const parseCSVFile = async (file: File): Promise<FAQEntryPayload[]> => {
answers: splitByDelimiter(record['机器人回答'] || record['answers']),
similar_questions: splitByDelimiter(record['相似问题'] || record['similar_questions']),
negative_questions: splitByDelimiter(record['反例问题'] || record['negative_questions']),
tag_id: record['tag_id'] || '',
tag_id: record['tag_id'] ? Number(record['tag_id']) : undefined,
tag_name: record['分类'] || record['tag_name'] || '',
is_enabled: isDisabled !== undefined ? !isDisabled : undefined, // 是否停用:FALSE表示启用,TRUE表示停用,所以取反
}),
@@ -2349,7 +2357,7 @@ const parseExcelFile = async (file: File): Promise<FAQEntryPayload[]> => {
answers: splitByDelimiter(normalizedRow['机器人回答'] || normalizedRow['answers']),
similar_questions: splitByDelimiter(normalizedRow['相似问题'] || normalizedRow['similar_questions']),
negative_questions: splitByDelimiter(normalizedRow['反例问题'] || normalizedRow['negative_questions']),
tag_id: normalizedRow['tag_id'] || '',
tag_id: normalizedRow['tag_id'] ? Number(normalizedRow['tag_id']) : undefined,
tag_name: normalizedRow['分类'] || normalizedRow['tag_name'] || '',
is_enabled: isDisabled !== undefined ? !isDisabled : undefined, // 是否停用:FALSE表示启用,TRUE表示停用,所以取反
})
@@ -2392,7 +2400,7 @@ const normalizePayload = (payload: Partial<FAQEntryPayload>): FAQEntryPayload =>
answers: payload.answers?.filter(Boolean) || [],
similar_questions: payload.similar_questions?.filter(Boolean) || [],
negative_questions: payload.negative_questions?.filter(Boolean) || [],
tag_id: payload.tag_id || '',
tag_id: payload.tag_id || undefined,
tag_name: payload.tag_name || '',
is_enabled: payload.is_enabled !== undefined ? payload.is_enabled : undefined,
})
+28
View File
@@ -44,6 +44,18 @@ func (r *chunkRepository) GetChunkByID(ctx context.Context, tenantID uint64, id
return &chunk, nil
}
// GetChunkBySeqID retrieves a chunk by its seq_id and tenant ID
func (r *chunkRepository) GetChunkBySeqID(ctx context.Context, tenantID uint64, seqID int64) (*types.Chunk, error) {
var chunk types.Chunk
if err := r.db.WithContext(ctx).Where("tenant_id = ? AND seq_id = ?", tenantID, seqID).First(&chunk).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("chunk not found")
}
return nil, err
}
return &chunk, nil
}
// ListChunksByID retrieves multiple chunks by their IDs
func (r *chunkRepository) ListChunksByID(
ctx context.Context, tenantID uint64, ids []string,
@@ -57,6 +69,22 @@ func (r *chunkRepository) ListChunksByID(
return chunks, nil
}
// ListChunksBySeqID retrieves multiple chunks by their seq_ids
func (r *chunkRepository) ListChunksBySeqID(
ctx context.Context, tenantID uint64, seqIDs []int64,
) ([]*types.Chunk, error) {
if len(seqIDs) == 0 {
return []*types.Chunk{}, nil
}
var chunks []*types.Chunk
if err := r.db.WithContext(ctx).
Where("tenant_id = ? AND seq_id IN ?", tenantID, seqIDs).
Find(&chunks).Error; err != nil {
return nil, err
}
return chunks, nil
}
// ListChunksByKnowledgeID lists all chunks for a knowledge ID
func (r *chunkRepository) ListChunksByKnowledgeID(
ctx context.Context, tenantID uint64, knowledgeID string,
+25
View File
@@ -54,6 +54,31 @@ func (r *knowledgeTagRepository) GetByIDs(ctx context.Context, tenantID uint64,
return tags, nil
}
// GetBySeqID retrieves a tag by its seq_id
func (r *knowledgeTagRepository) GetBySeqID(ctx context.Context, tenantID uint64, seqID int64) (*types.KnowledgeTag, error) {
var tag types.KnowledgeTag
if err := r.db.WithContext(ctx).
Where("tenant_id = ? AND seq_id = ?", tenantID, seqID).
First(&tag).Error; err != nil {
return nil, err
}
return &tag, nil
}
// GetBySeqIDs retrieves multiple tags by their seq_ids in a single query
func (r *knowledgeTagRepository) GetBySeqIDs(ctx context.Context, tenantID uint64, seqIDs []int64) ([]*types.KnowledgeTag, error) {
if len(seqIDs) == 0 {
return []*types.KnowledgeTag{}, nil
}
var tags []*types.KnowledgeTag
if err := r.db.WithContext(ctx).
Where("tenant_id = ? AND seq_id IN (?)", tenantID, seqIDs).
Find(&tags).Error; err != nil {
return nil, err
}
return tags, nil
}
// GetByName gets a knowledge tag by name
func (r *knowledgeTagRepository) GetByName(ctx context.Context, tenantID uint64, kbID string, name string) (*types.KnowledgeTag, error) {
var tag types.KnowledgeTag
@@ -375,7 +375,7 @@ func (s *agentService) getKnowledgeBaseInfos(ctx context.Context, kbIDs []string
pageResult, err := s.knowledgeService.ListFAQEntries(ctx, kbID, &types.Pagination{
Page: 1,
PageSize: 10,
}, "", "", "", "")
}, 0, "", "", "")
if err == nil && pageResult != nil {
docCount = int(pageResult.Total)
if entries, ok := pageResult.Data.([]*types.FAQEntry); ok {
+313 -122
View File
@@ -2650,7 +2650,7 @@ func (s *knowledgeService) CloneChunk(ctx context.Context, src, dst *types.Knowl
// ListFAQEntries lists FAQ entries under a FAQ knowledge base.
func (s *knowledgeService) ListFAQEntries(ctx context.Context,
kbID string, page *types.Pagination, tagID string, keyword string, searchField string, sortOrder string,
kbID string, page *types.Pagination, tagSeqID int64, keyword string, searchField string, sortOrder string,
) (*types.PageResult, error) {
if page == nil {
page = &types.Pagination{}
@@ -2668,6 +2668,17 @@ func (s *knowledgeService) ListFAQEntries(ctx context.Context,
if faqKnowledge == nil {
return types.NewPageResult(0, page, []*types.FAQEntry{}), nil
}
// Convert tagSeqID to tagID (UUID)
var tagID string
if tagSeqID > 0 {
tag, err := s.tagRepo.GetBySeqID(ctx, tenantID, tagSeqID)
if err != nil {
return nil, werrors.NewNotFoundError("标签不存在")
}
tagID = tag.ID
}
chunkType := []types.ChunkType{types.ChunkTypeFAQ}
chunks, total, err := s.chunkRepo.ListPagedChunksByKnowledgeID(
ctx, tenantID, faqKnowledge.ID, page, chunkType, tagID, keyword, searchField, sortOrder, types.KnowledgeTypeFAQ,
@@ -2676,8 +2687,9 @@ func (s *knowledgeService) ListFAQEntries(ctx context.Context,
return nil, err
}
// Build tag ID to name mapping for all unique tag IDs (batch query)
// Build tag ID to name and seq_id mapping for all unique tag IDs (batch query)
tagNameMap := make(map[string]string)
tagSeqIDMap := make(map[string]int64)
tagIDs := make([]string, 0)
tagIDSet := make(map[string]struct{})
for _, chunk := range chunks {
@@ -2693,6 +2705,7 @@ func (s *knowledgeService) ListFAQEntries(ctx context.Context,
if err == nil {
for _, tag := range tags {
tagNameMap[tag.ID] = tag.Name
tagSeqIDMap[tag.ID] = tag.SeqID
}
}
}
@@ -2700,13 +2713,13 @@ func (s *knowledgeService) ListFAQEntries(ctx context.Context,
kb.EnsureDefaults()
entries := make([]*types.FAQEntry, 0, len(chunks))
for _, chunk := range chunks {
entry, err := s.chunkToFAQEntry(chunk, kb)
entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
if err != nil {
return nil, err
}
// Set tag name from mapping
if entry.TagID != "" {
entry.TagName = tagNameMap[entry.TagID]
if chunk.TagID != "" {
entry.TagName = tagNameMap[chunk.TagID]
}
entries = append(entries, entry)
}
@@ -3555,6 +3568,10 @@ func (s *knowledgeService) executeFAQImport(ctx context.Context, taskID string,
TagID: tagID, // 使用解析后的 TagID
Status: int(types.ChunkStatusStored), // store but not indexed
}
// 如果指定了 ID(用于数据迁移),设置 SeqID
if entry.ID != nil && *entry.ID > 0 {
chunk.SeqID = *entry.ID
}
if err := chunk.SetFAQMetadata(meta); err != nil {
return fmt.Errorf("failed to set FAQ metadata: %w", err)
}
@@ -3719,6 +3736,10 @@ func (s *knowledgeService) CreateFAQEntry(ctx context.Context,
TagID: tagID, // 使用解析后的 TagID
Status: int(types.ChunkStatusStored),
}
// 如果指定了 ID(用于数据迁移),设置 SeqID
if payload.ID != nil && *payload.ID > 0 {
chunk.SeqID = *payload.ID
}
if err := chunk.SetFAQMetadata(meta); err != nil {
return nil, fmt.Errorf("failed to set FAQ metadata: %w", err)
@@ -3742,15 +3763,24 @@ func (s *knowledgeService) CreateFAQEntry(ctx context.Context,
return nil, fmt.Errorf("failed to update chunk status: %w", err)
}
// Build tag seq_id map for conversion
tagSeqIDMap := make(map[string]int64)
if chunk.TagID != "" {
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
if tagErr == nil && tag != nil {
tagSeqIDMap[tag.ID] = tag.SeqID
}
}
// 转换为FAQEntry返回
entry, err := s.chunkToFAQEntry(chunk, kb)
entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
if err != nil {
return nil, err
}
// 查询TagName
if entry.TagID != "" {
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, entry.TagID)
if chunk.TagID != "" {
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
if tagErr == nil && tag != nil {
entry.TagName = tag.Name
}
@@ -3759,11 +3789,11 @@ func (s *knowledgeService) CreateFAQEntry(ctx context.Context,
return entry, nil
}
// GetFAQEntry retrieves a single FAQ entry by ID.
// GetFAQEntry retrieves a single FAQ entry by seq_id.
func (s *knowledgeService) GetFAQEntry(ctx context.Context,
kbID string, entryID string,
kbID string, entrySeqID int64,
) (*types.FAQEntry, error) {
if entryID == "" {
if entrySeqID <= 0 {
return nil, werrors.NewBadRequestError("条目ID不能为空")
}
@@ -3775,10 +3805,10 @@ func (s *knowledgeService) GetFAQEntry(ctx context.Context,
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
// 获取chunk
chunk, err := s.chunkService.GetChunkByID(ctx, entryID)
// 获取chunk by seq_id
chunk, err := s.chunkRepo.GetChunkBySeqID(ctx, tenantID, entrySeqID)
if err != nil {
return nil, err
return nil, werrors.NewNotFoundError("FAQ条目不存在")
}
// 验证chunk属于当前知识库
@@ -3791,15 +3821,24 @@ func (s *knowledgeService) GetFAQEntry(ctx context.Context,
return nil, werrors.NewNotFoundError("FAQ条目不存在")
}
// Build tag seq_id map for conversion
tagSeqIDMap := make(map[string]int64)
if chunk.TagID != "" {
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
if tagErr == nil && tag != nil {
tagSeqIDMap[tag.ID] = tag.SeqID
}
}
// 转换为FAQEntry返回
entry, err := s.chunkToFAQEntry(chunk, kb)
entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
if err != nil {
return nil, err
}
// 查询TagName
if entry.TagID != "" {
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, entry.TagID)
if chunk.TagID != "" {
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
if tagErr == nil && tag != nil {
entry.TagName = tag.Name
}
@@ -3810,7 +3849,7 @@ func (s *knowledgeService) GetFAQEntry(ctx context.Context,
// UpdateFAQEntry updates a single FAQ entry.
func (s *knowledgeService) UpdateFAQEntry(ctx context.Context,
kbID string, entryID string, payload *types.FAQEntryPayload,
kbID string, entrySeqID int64, payload *types.FAQEntryPayload,
) (*types.FAQEntry, error) {
if payload == nil {
return nil, werrors.NewBadRequestError("请求体不能为空")
@@ -3821,9 +3860,10 @@ func (s *knowledgeService) UpdateFAQEntry(ctx context.Context,
}
kb.EnsureDefaults()
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
chunk, err := s.chunkRepo.GetChunkByID(ctx, tenantID, entryID)
chunk, err := s.chunkRepo.GetChunkBySeqID(ctx, tenantID, entrySeqID)
if err != nil {
return nil, err
return nil, werrors.NewNotFoundError("FAQ条目不存在")
}
if chunk.KnowledgeBaseID != kb.ID {
return nil, werrors.NewForbiddenError("无权操作该 FAQ 条目")
@@ -3837,7 +3877,7 @@ func (s *knowledgeService) UpdateFAQEntry(ctx context.Context,
}
// 检查标准问和相似问是否与其他条目重复
if err := s.checkFAQQuestionDuplicate(ctx, tenantID, kb.ID, entryID, meta); err != nil {
if err := s.checkFAQQuestionDuplicate(ctx, tenantID, kb.ID, chunk.ID, meta); err != nil {
return nil, err
}
@@ -3853,8 +3893,19 @@ func (s *knowledgeService) UpdateFAQEntry(ctx context.Context,
indexMode = kb.FAQConfig.IndexMode
}
chunk.Content = buildFAQChunkContent(meta, indexMode)
chunk.TagID = payload.TagID
isEnabledUpdated := false
// Convert tag seq_id to UUID
if payload.TagID > 0 {
tag, tagErr := s.tagRepo.GetBySeqID(ctx, tenantID, payload.TagID)
if tagErr != nil {
return nil, werrors.NewNotFoundError("标签不存在")
}
chunk.TagID = tag.ID
} else {
chunk.TagID = ""
}
if payload.IsEnabled != nil {
oldEnabled := chunk.IsEnabled
chunk.IsEnabled = *payload.IsEnabled
@@ -3901,15 +3952,24 @@ func (s *knowledgeService) UpdateFAQEntry(ctx context.Context,
return nil, err
}
// Build tag seq_id map for conversion
tagSeqIDMap := make(map[string]int64)
if chunk.TagID != "" {
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
if tagErr == nil && tag != nil {
tagSeqIDMap[tag.ID] = tag.SeqID
}
}
// 转换为FAQEntry返回
entry, err := s.chunkToFAQEntry(chunk, kb)
entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
if err != nil {
return nil, err
}
// 查询TagName
if entry.TagID != "" {
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, entry.TagID)
if chunk.TagID != "" {
tag, tagErr := s.tagRepo.GetByID(ctx, tenantID, chunk.TagID)
if tagErr == nil && tag != nil {
entry.TagName = tag.Name
}
@@ -3960,8 +4020,8 @@ func (s *knowledgeService) UpdateFAQEntryStatus(ctx context.Context,
// UpdateFAQEntryFieldsBatch updates multiple fields for FAQ entries in batch.
// This is the unified API for batch updating FAQ entry fields.
// Supports two modes:
// 1. By entry ID: use ByID field
// 2. By Tag: use ByTag field to apply the same update to all entries under a tag
// 1. By entry seq_id: use ByID field
// 2. By Tag seq_id: use ByTag field to apply the same update to all entries under a tag
func (s *knowledgeService) UpdateFAQEntryFieldsBatch(ctx context.Context,
kbID string, req *types.FAQEntryFieldsBatchUpdate,
) error {
@@ -3977,9 +4037,26 @@ func (s *knowledgeService) UpdateFAQEntryFieldsBatch(ctx context.Context,
enabledUpdates := make(map[string]bool)
tagUpdates := make(map[string]string)
// Handle ByTag updates first
// Convert exclude seq_ids to UUIDs
excludeUUIDs := make([]string, 0, len(req.ExcludeIDs))
if len(req.ExcludeIDs) > 0 {
excludeChunks, err := s.chunkRepo.ListChunksBySeqID(ctx, tenantID, req.ExcludeIDs)
if err == nil {
for _, c := range excludeChunks {
excludeUUIDs = append(excludeUUIDs, c.ID)
}
}
}
// Handle ByTag updates first (by tag seq_id)
if len(req.ByTag) > 0 {
for tagID, update := range req.ByTag {
for tagSeqID, update := range req.ByTag {
// Convert tag seq_id to UUID
tag, err := s.tagRepo.GetBySeqID(ctx, tenantID, tagSeqID)
if err != nil {
return werrors.NewNotFoundError(fmt.Sprintf("标签 %d 不存在", tagSeqID))
}
var setFlags, clearFlags types.ChunkFlags
// Handle IsRecommended
@@ -3991,10 +4068,25 @@ func (s *knowledgeService) UpdateFAQEntryFieldsBatch(ctx context.Context,
}
}
// Convert new tag seq_id to UUID if provided
var newTagUUID *string
if update.TagID != nil {
if *update.TagID > 0 {
newTag, err := s.tagRepo.GetBySeqID(ctx, tenantID, *update.TagID)
if err != nil {
return werrors.NewNotFoundError(fmt.Sprintf("标签 %d 不存在", *update.TagID))
}
newTagUUID = &newTag.ID
} else {
emptyStr := ""
newTagUUID = &emptyStr
}
}
// Update all chunks with this tag
affectedIDs, err := s.chunkRepo.UpdateChunkFieldsByTagID(
ctx, tenantID, kb.ID, tagID,
update.IsEnabled, setFlags, clearFlags, update.TagID, req.ExcludeIDs,
ctx, tenantID, kb.ID, tag.ID,
update.IsEnabled, setFlags, clearFlags, newTagUUID, excludeUUIDs,
)
if err != nil {
return err
@@ -4007,36 +4099,42 @@ func (s *knowledgeService) UpdateFAQEntryFieldsBatch(ctx context.Context,
enabledUpdates[id] = *update.IsEnabled
}
}
if update.TagID != nil {
if newTagUUID != nil {
for _, id := range affectedIDs {
tagUpdates[id] = *update.TagID
tagUpdates[id] = *newTagUUID
}
}
}
}
}
// Handle ByID updates
// Handle ByID updates (by entry seq_id)
if len(req.ByID) > 0 {
entryIDs := make([]string, 0, len(req.ByID))
for entryID := range req.ByID {
entryIDs = append(entryIDs, entryID)
entrySeqIDs := make([]int64, 0, len(req.ByID))
for entrySeqID := range req.ByID {
entrySeqIDs = append(entrySeqIDs, entrySeqID)
}
chunks, err := s.chunkRepo.ListChunksByID(ctx, tenantID, entryIDs)
chunks, err := s.chunkRepo.ListChunksBySeqID(ctx, tenantID, entrySeqIDs)
if err != nil {
return err
}
// Build chunk seq_id to chunk map
chunkBySeqID := make(map[int64]*types.Chunk)
for _, chunk := range chunks {
chunkBySeqID[chunk.SeqID] = chunk
}
setFlags := make(map[string]types.ChunkFlags)
clearFlags := make(map[string]types.ChunkFlags)
chunksToUpdate := make([]*types.Chunk, 0)
for _, chunk := range chunks {
if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ {
for entrySeqID, update := range req.ByID {
chunk, exists := chunkBySeqID[entrySeqID]
if !exists {
continue
}
update, exists := req.ByID[chunk.ID]
if !exists {
if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ {
continue
}
@@ -4061,11 +4159,15 @@ func (s *knowledgeService) UpdateFAQEntryFieldsBatch(ctx context.Context,
}
}
// Handle TagID
// Handle TagID (convert seq_id to UUID)
if update.TagID != nil {
newTagID := ""
if *update.TagID != "" {
newTagID = *update.TagID
var newTagID string
if *update.TagID > 0 {
newTag, err := s.tagRepo.GetBySeqID(ctx, tenantID, *update.TagID)
if err != nil {
return werrors.NewNotFoundError(fmt.Sprintf("标签 %d 不存在", *update.TagID))
}
newTagID = newTag.ID
}
if chunk.TagID != newTagID {
chunk.TagID = newTagID
@@ -4267,7 +4369,8 @@ func (s *knowledgeService) UpdateFAQEntryTag(ctx context.Context, kbID string, e
}
// UpdateFAQEntryTagBatch updates tags for FAQ entries in batch.
func (s *knowledgeService) UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[string]*string) error {
// Key: entry seq_id, Value: tag seq_id (nil to remove tag)
func (s *knowledgeService) UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[int64]*int64) error {
if len(updates) == 0 {
return nil
}
@@ -4277,59 +4380,65 @@ func (s *knowledgeService) UpdateFAQEntryTagBatch(ctx context.Context, kbID stri
}
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
// Get all chunks in batch
entryIDs := make([]string, 0, len(updates))
for entryID := range updates {
entryIDs = append(entryIDs, entryID)
// Get all chunks in batch by seq_id
entrySeqIDs := make([]int64, 0, len(updates))
for entrySeqID := range updates {
entrySeqIDs = append(entrySeqIDs, entrySeqID)
}
chunks, err := s.chunkRepo.ListChunksByID(ctx, tenantID, entryIDs)
chunks, err := s.chunkRepo.ListChunksBySeqID(ctx, tenantID, entrySeqIDs)
if err != nil {
return err
}
// Build tag ID map for validation
tagIDSet := make(map[string]bool)
for _, tagID := range updates {
if tagID != nil && *tagID != "" {
tagIDSet[*tagID] = true
// Build chunk seq_id to chunk map
chunkBySeqID := make(map[int64]*types.Chunk)
for _, chunk := range chunks {
chunkBySeqID[chunk.SeqID] = chunk
}
// Build tag seq_id set for validation
tagSeqIDSet := make(map[int64]bool)
for _, tagSeqID := range updates {
if tagSeqID != nil && *tagSeqID > 0 {
tagSeqIDSet[*tagSeqID] = true
}
}
// Validate all tags in batch
tagMap := make(map[string]*types.KnowledgeTag)
if len(tagIDSet) > 0 {
tagIDs := make([]string, 0, len(tagIDSet))
for tagID := range tagIDSet {
tagIDs = append(tagIDs, tagID)
// Validate all tags in batch by seq_id
tagMap := make(map[int64]*types.KnowledgeTag)
if len(tagSeqIDSet) > 0 {
tagSeqIDs := make([]int64, 0, len(tagSeqIDSet))
for tagSeqID := range tagSeqIDSet {
tagSeqIDs = append(tagSeqIDs, tagSeqID)
}
for _, tagID := range tagIDs {
tag, err := s.tagRepo.GetByID(ctx, tenantID, tagID)
if err != nil {
return err
}
tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, tagSeqIDs)
if err != nil {
return err
}
for _, tag := range tags {
if tag.KnowledgeBaseID != kb.ID {
return werrors.NewBadRequestError(fmt.Sprintf("标签 %s 不属于当前知识库", tagID))
return werrors.NewBadRequestError(fmt.Sprintf("标签 %d 不属于当前知识库", tag.SeqID))
}
tagMap[tagID] = tag
tagMap[tag.SeqID] = tag
}
}
// Update chunks
chunksToUpdate := make([]*types.Chunk, 0)
for _, chunk := range chunks {
if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ {
for entrySeqID, tagSeqID := range updates {
chunk, exists := chunkBySeqID[entrySeqID]
if !exists {
continue
}
tagID, exists := updates[chunk.ID]
if !exists {
if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ {
continue
}
var resolvedTagID string
if tagID != nil && *tagID != "" {
tag, ok := tagMap[*tagID]
if tagSeqID != nil && *tagSeqID > 0 {
tag, ok := tagMap[*tagSeqID]
if !ok {
return werrors.NewBadRequestError(fmt.Sprintf("标签 %s 不存在", *tagID))
return werrors.NewBadRequestError(fmt.Sprintf("标签 %d 不存在", *tagSeqID))
}
resolvedTagID = tag.ID
}
@@ -4386,17 +4495,45 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context,
req.MatchCount = 50
}
// Build priority tag sets for sorting
hasFirstPriority := len(req.FirstPriorityTagIDs) > 0
hasSecondPriority := len(req.SecondPriorityTagIDs) > 0
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
// Convert tag seq_ids to UUIDs
var firstPriorityTagUUIDs, secondPriorityTagUUIDs []string
firstPrioritySeqIDSet := make(map[int64]struct{})
secondPrioritySeqIDSet := make(map[int64]struct{})
if len(req.FirstPriorityTagIDs) > 0 {
tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, req.FirstPriorityTagIDs)
if err == nil {
firstPriorityTagUUIDs = make([]string, 0, len(tags))
for _, tag := range tags {
firstPriorityTagUUIDs = append(firstPriorityTagUUIDs, tag.ID)
firstPrioritySeqIDSet[tag.SeqID] = struct{}{}
}
}
}
if len(req.SecondPriorityTagIDs) > 0 {
tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, req.SecondPriorityTagIDs)
if err == nil {
secondPriorityTagUUIDs = make([]string, 0, len(tags))
for _, tag := range tags {
secondPriorityTagUUIDs = append(secondPriorityTagUUIDs, tag.ID)
secondPrioritySeqIDSet[tag.SeqID] = struct{}{}
}
}
}
// Build priority tag sets for sorting (using UUID)
hasFirstPriority := len(firstPriorityTagUUIDs) > 0
hasSecondPriority := len(secondPriorityTagUUIDs) > 0
hasPriorityFilter := hasFirstPriority || hasSecondPriority
firstPrioritySet := make(map[string]struct{}, len(req.FirstPriorityTagIDs))
for _, tagID := range req.FirstPriorityTagIDs {
firstPrioritySet := make(map[string]struct{}, len(firstPriorityTagUUIDs))
for _, tagID := range firstPriorityTagUUIDs {
firstPrioritySet[tagID] = struct{}{}
}
secondPrioritySet := make(map[string]struct{}, len(req.SecondPriorityTagIDs))
for _, tagID := range req.SecondPriorityTagIDs {
secondPrioritySet := make(map[string]struct{}, len(secondPriorityTagUUIDs))
for _, tagID := range secondPriorityTagUUIDs {
secondPrioritySet[tagID] = struct{}{}
}
@@ -4423,7 +4560,8 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context,
VectorThreshold: req.VectorThreshold,
MatchCount: req.MatchCount,
DisableKeywordsMatch: true,
TagIDs: req.FirstPriorityTagIDs,
TagIDs: firstPriorityTagUUIDs,
OnlyRecommended: req.OnlyRecommended,
}
firstResults, firstErr = s.kbService.HybridSearch(ctx, kbID, firstParams)
}()
@@ -4438,7 +4576,8 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context,
VectorThreshold: req.VectorThreshold,
MatchCount: req.MatchCount,
DisableKeywordsMatch: true,
TagIDs: req.SecondPriorityTagIDs,
TagIDs: secondPriorityTagUUIDs,
OnlyRecommended: req.OnlyRecommended,
}
secondResults, secondErr = s.kbService.HybridSearch(ctx, kbID, secondParams)
}()
@@ -4500,12 +4639,32 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context,
}
// Batch fetch chunks
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
chunks, err := s.chunkRepo.ListChunksByID(ctx, tenantID, chunkIDs)
if err != nil {
return nil, err
}
// Build tag UUID to seq_id map for conversion
tagSeqIDMap := make(map[string]int64)
tagIDs := make([]string, 0)
tagIDSet := make(map[string]struct{})
for _, chunk := range chunks {
if chunk.TagID != "" {
if _, exists := tagIDSet[chunk.TagID]; !exists {
tagIDSet[chunk.TagID] = struct{}{}
tagIDs = append(tagIDs, chunk.TagID)
}
}
}
if len(tagIDs) > 0 {
tags, err := s.tagRepo.GetByIDs(ctx, tenantID, tagIDs)
if err == nil {
for _, tag := range tags {
tagSeqIDMap[tag.ID] = tag.SeqID
}
}
}
// Filter FAQ chunks and convert to FAQEntry
kb.EnsureDefaults()
entries := make([]*types.FAQEntry, 0, len(chunks))
@@ -4518,7 +4677,7 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context,
continue
}
entry, err := s.chunkToFAQEntry(chunk, kb)
entry, err := s.chunkToFAQEntry(chunk, kb, tagSeqIDMap)
if err != nil {
logger.Warnf(ctx, "Failed to convert chunk to FAQ entry: %v", err)
continue
@@ -4539,19 +4698,37 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context,
// Sort entries with two-level priority tag support
if hasPriorityFilter {
// getPriorityLevel returns: 0 = first priority, 1 = second priority, 2 = no priority
getPriorityLevel := func(tagID string) int {
if _, ok := firstPrioritySet[tagID]; ok {
// Use chunk.TagID (UUID) for comparison
getPriorityLevel := func(chunk *types.Chunk) int {
if _, ok := firstPrioritySet[chunk.TagID]; ok {
return 0
}
if _, ok := secondPrioritySet[tagID]; ok {
if _, ok := secondPrioritySet[chunk.TagID]; ok {
return 1
}
return 2
}
// Build chunk map for priority lookup
chunkMap := make(map[int64]*types.Chunk)
for _, chunk := range chunks {
chunkMap[chunk.SeqID] = chunk
}
slices.SortFunc(entries, func(a, b *types.FAQEntry) int {
aPriority := getPriorityLevel(a.TagID)
bPriority := getPriorityLevel(b.TagID)
aChunk := chunkMap[a.ID]
bChunk := chunkMap[b.ID]
var aPriority, bPriority int
if aChunk != nil {
aPriority = getPriorityLevel(aChunk)
} else {
aPriority = 2
}
if bChunk != nil {
bPriority = getPriorityLevel(bChunk)
} else {
bPriority = 2
}
// Compare by priority level first
if aPriority != bPriority {
@@ -4585,33 +4762,33 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context,
// 批量查询TagName并补充到结果中
if len(entries) > 0 {
// 收集所有需要查询的TagID
tagIDs := make([]string, 0)
tagIDSet := make(map[string]struct{})
// 收集所有需要查询的TagID (seq_id)
tagSeqIDs := make([]int64, 0)
tagSeqIDSet := make(map[int64]struct{})
for _, entry := range entries {
if entry.TagID != "" {
if _, exists := tagIDSet[entry.TagID]; !exists {
tagIDs = append(tagIDs, entry.TagID)
tagIDSet[entry.TagID] = struct{}{}
if entry.TagID != 0 {
if _, exists := tagSeqIDSet[entry.TagID]; !exists {
tagSeqIDs = append(tagSeqIDs, entry.TagID)
tagSeqIDSet[entry.TagID] = struct{}{}
}
}
}
// 批量查询标签
if len(tagIDs) > 0 {
tags, err := s.tagRepo.GetByIDs(ctx, tenantID, tagIDs)
if len(tagSeqIDs) > 0 {
tags, err := s.tagRepo.GetBySeqIDs(ctx, tenantID, tagSeqIDs)
if err != nil {
logger.Warnf(ctx, "Failed to batch query tags: %v", err)
} else {
// 构建TagID到TagName的映射
tagNameMap := make(map[string]string)
// 构建TagSeqID到TagName的映射
tagNameMap := make(map[int64]string)
for _, tag := range tags {
tagNameMap[tag.ID] = tag.Name
tagNameMap[tag.SeqID] = tag.Name
}
// 补充TagName
for _, entry := range entries {
if entry.TagID != "" {
if entry.TagID != 0 {
if tagName, exists := tagNameMap[entry.TagID]; exists {
entry.TagName = tagName
}
@@ -4624,11 +4801,11 @@ func (s *knowledgeService) SearchFAQEntries(ctx context.Context,
return entries, nil
}
// DeleteFAQEntries deletes FAQ entries in batch.
// DeleteFAQEntries deletes FAQ entries in batch by seq_id.
func (s *knowledgeService) DeleteFAQEntries(ctx context.Context,
kbID string, entryIDs []string,
kbID string, entrySeqIDs []int64,
) error {
if len(entryIDs) == 0 {
if len(entrySeqIDs) == 0 {
return werrors.NewBadRequestError("请选择需要删除的 FAQ 条目")
}
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
@@ -4638,19 +4815,19 @@ func (s *knowledgeService) DeleteFAQEntries(ctx context.Context,
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
var faqKnowledge *types.Knowledge
chunksToRemove := make([]*types.Chunk, 0, len(entryIDs))
for _, id := range entryIDs {
if id == "" {
chunksToRemove := make([]*types.Chunk, 0, len(entrySeqIDs))
for _, seqID := range entrySeqIDs {
if seqID <= 0 {
continue
}
chunk, err := s.chunkRepo.GetChunkByID(ctx, tenantID, id)
chunk, err := s.chunkRepo.GetChunkBySeqID(ctx, tenantID, seqID)
if err != nil {
return err
return werrors.NewNotFoundError("FAQ条目不存在")
}
if chunk.KnowledgeBaseID != kb.ID || chunk.ChunkType != types.ChunkTypeFAQ {
return werrors.NewBadRequestError("包含无效的 FAQ 条目")
}
if err := s.chunkService.DeleteChunk(ctx, id); err != nil {
if err := s.chunkService.DeleteChunk(ctx, chunk.ID); err != nil {
return err
}
if faqKnowledge == nil {
@@ -4923,7 +5100,7 @@ func (s *knowledgeService) clearRunningFAQImportTaskID(ctx context.Context, kbID
return s.redisClient.Del(ctx, key).Err()
}
func (s *knowledgeService) chunkToFAQEntry(chunk *types.Chunk, kb *types.KnowledgeBase) (*types.FAQEntry, error) {
func (s *knowledgeService) chunkToFAQEntry(chunk *types.Chunk, kb *types.KnowledgeBase, tagSeqIDMap map[string]int64) (*types.FAQEntry, error) {
meta, err := chunk.FAQMetadata()
if err != nil {
return nil, err
@@ -4936,12 +5113,19 @@ func (s *knowledgeService) chunkToFAQEntry(chunk *types.Chunk, kb *types.Knowled
if answerStrategy == "" {
answerStrategy = types.AnswerStrategyAll
}
// Get tag seq_id from map
var tagSeqID int64
if chunk.TagID != "" && tagSeqIDMap != nil {
tagSeqID = tagSeqIDMap[chunk.TagID]
}
entry := &types.FAQEntry{
ID: chunk.ID,
ID: chunk.SeqID,
ChunkID: chunk.ID,
KnowledgeID: chunk.KnowledgeID,
KnowledgeBaseID: chunk.KnowledgeBaseID,
TagID: chunk.TagID,
TagID: tagSeqID,
IsEnabled: chunk.IsEnabled,
IsRecommended: chunk.Flags.HasFlag(types.ChunkFlagRecommended),
StandardQuestion: meta.StandardQuestion,
@@ -5036,12 +5220,19 @@ func (s *knowledgeService) checkFAQQuestionDuplicate(
return nil
}
// resolveTagID resolves tag ID from payload, prioritizing tag_id over tag_name
// resolveTagID resolves tag ID (UUID) from payload, prioritizing tag_id (seq_id) over tag_name
// If no tag is specified, creates or finds the "未分类" tag
// Returns the internal UUID of the tag
func (s *knowledgeService) resolveTagID(ctx context.Context, kbID string, payload *types.FAQEntryPayload) (string, error) {
// 如果提供了 tag_id,优先使用 tag_id
if payload.TagID != "" {
return payload.TagID, nil
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
// 如果提供了 tag_id (seq_id),优先使用 tag_id
if payload.TagID != 0 {
tag, err := s.tagRepo.GetBySeqID(ctx, tenantID, payload.TagID)
if err != nil {
return "", fmt.Errorf("failed to find tag by seq_id %d: %w", payload.TagID, err)
}
return tag.ID, nil
}
// 如果提供了 tag_name,查找或创建标签
+33 -12
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
@@ -31,7 +32,7 @@ func NewFAQHandler(knowledgeService interfaces.KnowledgeService) *FAQHandler {
// @Param id path string true "知识库ID"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param tag_id query string false "标签ID筛选"
// @Param tag_id query int false "标签ID筛选(seq_id)"
// @Param keyword query string false "关键词搜索"
// @Param search_field query string false "搜索字段: standard_question(标准问题), similar_questions(相似问法), answers(答案), 默认搜索全部"
// @Param sort_order query string false "排序方式: asc(按更新时间正序), 默认按更新时间倒序"
@@ -49,12 +50,21 @@ func (h *FAQHandler) ListEntries(c *gin.Context) {
return
}
tagID := secutils.SanitizeForLog(c.Query("tag_id"))
var tagSeqID int64
tagIDStr := c.Query("tag_id")
if tagIDStr != "" {
var err error
tagSeqID, err = strconv.ParseInt(tagIDStr, 10, 64)
if err != nil {
c.Error(errors.NewBadRequestError("tag_id 必须是整数"))
return
}
}
keyword := secutils.SanitizeForLog(c.Query("keyword"))
searchField := secutils.SanitizeForLog(c.Query("search_field"))
sortOrder := secutils.SanitizeForLog(c.Query("sort_order"))
result, err := h.knowledgeService.ListFAQEntries(ctx, secutils.SanitizeForLog(c.Param("id")), &page, tagID, keyword, searchField, sortOrder)
result, err := h.knowledgeService.ListFAQEntries(ctx, secutils.SanitizeForLog(c.Param("id")), &page, tagSeqID, keyword, searchField, sortOrder)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
@@ -151,7 +161,7 @@ func (h *FAQHandler) CreateEntry(c *gin.Context) {
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param entry_id path string true "FAQ条目ID"
// @Param entry_id path int true "FAQ条目ID(seq_id)"
// @Param request body types.FAQEntryPayload true "FAQ条目"
// @Success 200 {object} map[string]interface{} "更新成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
@@ -167,8 +177,14 @@ func (h *FAQHandler) UpdateEntry(c *gin.Context) {
return
}
entrySeqID, err := strconv.ParseInt(c.Param("entry_id"), 10, 64)
if err != nil {
c.Error(errors.NewBadRequestError("entry_id 必须是整数"))
return
}
entry, err := h.knowledgeService.UpdateFAQEntry(ctx,
secutils.SanitizeForLog(c.Param("id")), secutils.SanitizeForLog(c.Param("entry_id")), &req)
secutils.SanitizeForLog(c.Param("id")), entrySeqID, &req)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
@@ -247,12 +263,13 @@ func (h *FAQHandler) UpdateEntryFieldsBatch(c *gin.Context) {
// faqDeleteRequest is a request for deleting FAQ entries in batch
type faqDeleteRequest struct {
IDs []string `json:"ids" binding:"required,min=1,dive,required"`
IDs []int64 `json:"ids" binding:"required,min=1"`
}
// faqEntryTagBatchRequest is a request for updating tags for FAQ entries in batch
// key: entry seq_id, value: tag seq_id (nil to remove tag)
type faqEntryTagBatchRequest struct {
Updates map[string]*string `json:"updates" binding:"required,min=1"`
Updates map[int64]*int64 `json:"updates" binding:"required,min=1"`
}
// DeleteEntries godoc
@@ -262,7 +279,7 @@ type faqEntryTagBatchRequest struct {
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param request body object{ids=[]string} true "要删除的FAQ ID列表"
// @Param request body object{ids=[]int} true "要删除的FAQ ID列表(seq_id)"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
@@ -279,7 +296,7 @@ func (h *FAQHandler) DeleteEntries(c *gin.Context) {
if err := h.knowledgeService.DeleteFAQEntries(ctx,
secutils.SanitizeForLog(c.Param("id")),
secutils.SanitizeForLogArray(req.IDs)); err != nil {
req.IDs); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
return
@@ -369,7 +386,7 @@ func (h *FAQHandler) ExportEntries(c *gin.Context) {
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param entry_id path string true "FAQ条目ID"
// @Param entry_id path int true "FAQ条目ID(seq_id)"
// @Success 200 {object} map[string]interface{} "FAQ条目详情"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "条目不存在"
@@ -379,9 +396,13 @@ func (h *FAQHandler) ExportEntries(c *gin.Context) {
func (h *FAQHandler) GetEntry(c *gin.Context) {
ctx := c.Request.Context()
kbID := secutils.SanitizeForLog(c.Param("id"))
entryID := secutils.SanitizeForLog(c.Param("entry_id"))
entrySeqID, err := strconv.ParseInt(c.Param("entry_id"), 10, 64)
if err != nil {
c.Error(errors.NewBadRequestError("entry_id 必须是整数"))
return
}
entry, err := h.knowledgeService.GetFAQEntry(ctx, kbID, entryID)
entry, err := h.knowledgeService.GetFAQEntry(ctx, kbID, entrySeqID)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(err)
+63 -8
View File
@@ -1,7 +1,9 @@
package handler
import (
"context"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
@@ -15,16 +17,44 @@ import (
// TagHandler handles knowledge base tag operations.
type TagHandler struct {
tagService interfaces.KnowledgeTagService
tagRepo interfaces.KnowledgeTagRepository
chunkRepo interfaces.ChunkRepository
}
// DeleteTagRequest represents the request body for deleting a tag
type DeleteTagRequest struct {
ExcludeIDs []string `json:"exclude_ids"` // Chunk IDs to exclude from deletion
ExcludeIDs []int64 `json:"exclude_ids"` // Chunk seq_ids to exclude from deletion
}
// NewTagHandler creates a new TagHandler.
func NewTagHandler(tagService interfaces.KnowledgeTagService) *TagHandler {
return &TagHandler{tagService: tagService}
func NewTagHandler(tagService interfaces.KnowledgeTagService, tagRepo interfaces.KnowledgeTagRepository, chunkRepo interfaces.ChunkRepository) *TagHandler {
return &TagHandler{tagService: tagService, tagRepo: tagRepo, chunkRepo: chunkRepo}
}
// resolveTagID resolves tag_id parameter which can be either UUID or seq_id (integer).
// Returns the UUID of the tag.
func (h *TagHandler) resolveTagID(c *gin.Context) (string, error) {
ctx := c.Request.Context()
tagIDParam := secutils.SanitizeForLog(c.Param("tag_id"))
// Try to parse as integer (seq_id)
if seqID, err := strconv.ParseInt(tagIDParam, 10, 64); err == nil {
// It's an integer, look up by seq_id
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
tag, err := h.tagRepo.GetBySeqID(ctx, tenantID, seqID)
if err != nil {
return "", errors.NewNotFoundError("标签不存在")
}
return tag.ID, nil
}
// It's a UUID string
return tagIDParam, nil
}
// getChunksBySeqIDs retrieves chunks by their seq_ids.
func (h *TagHandler) getChunksBySeqIDs(ctx context.Context, tenantID uint64, seqIDs []int64) ([]*types.Chunk, error) {
return h.chunkRepo.ListChunksBySeqID(ctx, tenantID, seqIDs)
}
// ListTags godoc
@@ -127,7 +157,7 @@ type updateTagRequest struct {
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param tag_id path string true "标签ID"
// @Param tag_id path string true "标签ID (UUID或seq_id)"
// @Param request body object true "标签更新信息"
// @Success 200 {object} map[string]interface{} "更新后的标签"
// @Failure 400 {object} errors.AppError "请求参数错误"
@@ -137,7 +167,12 @@ type updateTagRequest struct {
func (h *TagHandler) UpdateTag(c *gin.Context) {
ctx := c.Request.Context()
tagID := secutils.SanitizeForLog(c.Param("tag_id"))
tagID, err := h.resolveTagID(c)
if err != nil {
c.Error(err)
return
}
var req updateTagRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to bind update tag payload", err)
@@ -167,7 +202,7 @@ func (h *TagHandler) UpdateTag(c *gin.Context) {
// @Accept json
// @Produce json
// @Param id path string true "知识库ID"
// @Param tag_id path string true "标签ID"
// @Param tag_id path string true "标签ID (UUID或seq_id)"
// @Param force query bool false "强制删除"
// @Param content_only query bool false "仅删除内容,保留标签"
// @Param body body DeleteTagRequest false "删除选项"
@@ -178,7 +213,12 @@ func (h *TagHandler) UpdateTag(c *gin.Context) {
// @Router /knowledge-bases/{id}/tags/{tag_id} [delete]
func (h *TagHandler) DeleteTag(c *gin.Context) {
ctx := c.Request.Context()
tagID := secutils.SanitizeForLog(c.Param("tag_id"))
tagID, err := h.resolveTagID(c)
if err != nil {
c.Error(err)
return
}
force := c.Query("force") == "true"
contentOnly := c.Query("content_only") == "true"
@@ -187,7 +227,22 @@ func (h *TagHandler) DeleteTag(c *gin.Context) {
// Ignore bind error since body is optional
_ = c.ShouldBindJSON(&req)
if err := h.tagService.DeleteTag(ctx, tagID, force, contentOnly, req.ExcludeIDs); err != nil {
// Convert seq_ids to UUIDs for excludeIDs
var excludeUUIDs []string
if len(req.ExcludeIDs) > 0 {
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
chunks, err := h.getChunksBySeqIDs(ctx, tenantID, req.ExcludeIDs)
if err != nil {
logger.Warnf(ctx, "Failed to resolve exclude_ids: %v", err)
} else {
excludeUUIDs = make([]string, len(chunks))
for i, chunk := range chunks {
excludeUUIDs[i] = chunk.ID
}
}
}
if err := h.tagService.DeleteTag(ctx, tagID, force, contentOnly, excludeUUIDs); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tag_id": tagID,
})
+2
View File
@@ -102,6 +102,8 @@ type ImageInfo struct {
type Chunk struct {
// Unique identifier of the chunk, using UUID format
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
// SeqID is an auto-increment integer ID for external API usage (FAQ entries)
SeqID int64 `json:"seq_id" gorm:"type:bigint;uniqueIndex"`
// Tenant ID, used for multi-tenant isolation
TenantID uint64 `json:"tenant_id"`
// ID of the parent knowledge, associated with the Knowledge model
+31 -28
View File
@@ -175,11 +175,11 @@ const (
// FAQEntry 表示返回给前端的 FAQ 条目
type FAQEntry struct {
ID string `json:"id"`
ID int64 `json:"id"`
ChunkID string `json:"chunk_id"`
KnowledgeID string `json:"knowledge_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
TagID string `json:"tag_id"`
TagID int64 `json:"tag_id"`
TagName string `json:"tag_name"`
IsEnabled bool `json:"is_enabled"`
IsRecommended bool `json:"is_recommended"`
@@ -198,12 +198,14 @@ type FAQEntry struct {
// FAQEntryPayload 用于创建/更新 FAQ 条目的 payload
type FAQEntryPayload struct {
// ID 可选,用于数据迁移时指定 seq_id(必须小于自增起始值 100000000)
ID *int64 `json:"id,omitempty"`
StandardQuestion string `json:"standard_question" binding:"required"`
SimilarQuestions []string `json:"similar_questions"`
NegativeQuestions []string `json:"negative_questions"`
Answers []string `json:"answers" binding:"required"`
AnswerStrategy *AnswerStrategy `json:"answer_strategy,omitempty"`
TagID string `json:"tag_id"`
TagID int64 `json:"tag_id"`
TagName string `json:"tag_name"`
IsEnabled *bool `json:"is_enabled,omitempty"`
IsRecommended *bool `json:"is_recommended,omitempty"`
@@ -247,11 +249,12 @@ type FAQDryRunResult struct {
// FAQSearchRequest FAQ检索请求参数
type FAQSearchRequest struct {
QueryText string `json:"query_text" binding:"required"`
VectorThreshold float64 `json:"vector_threshold"`
MatchCount int `json:"match_count"`
FirstPriorityTagIDs []string `json:"first_priority_tag_ids"` // 第一优先级标签ID列表,限定命中范围,优先级最高
SecondPriorityTagIDs []string `json:"second_priority_tag_ids"` // 第二优先级标签ID列表,限定命中范围,优先级低于第一优先级
QueryText string `json:"query_text" binding:"required"`
VectorThreshold float64 `json:"vector_threshold"`
MatchCount int `json:"match_count"`
FirstPriorityTagIDs []int64 `json:"first_priority_tag_ids"` // 第一优先级标签ID列表,限定命中范围,优先级最高
SecondPriorityTagIDs []int64 `json:"second_priority_tag_ids"` // 第二优先级标签ID列表,限定命中范围,优先级低于第一优先级
OnlyRecommended bool `json:"only_recommended"` // 是否仅返回推荐的条目
}
// UntaggedTagName is the default tag name for entries without a tag
@@ -259,9 +262,9 @@ const UntaggedTagName = "未分类"
// FAQEntryFieldsUpdate 单个FAQ条目的字段更新
type FAQEntryFieldsUpdate struct {
IsEnabled *bool `json:"is_enabled,omitempty"`
IsRecommended *bool `json:"is_recommended,omitempty"`
TagID *string `json:"tag_id,omitempty"`
IsEnabled *bool `json:"is_enabled,omitempty"`
IsRecommended *bool `json:"is_recommended,omitempty"`
TagID *int64 `json:"tag_id,omitempty"`
// 后续可扩展更多字段
}
@@ -270,12 +273,12 @@ type FAQEntryFieldsUpdate struct {
// 1. 按条目ID更新:使用 ByID 字段
// 2. 按Tag更新:使用 ByTag 字段,将该Tag下所有条目应用相同的更新
type FAQEntryFieldsBatchUpdate struct {
// ByID 按条目ID更新,key为条目ID
ByID map[string]FAQEntryFieldsUpdate `json:"by_id,omitempty"`
// ByTag 按Tag批量更新,key为TagID
ByTag map[string]FAQEntryFieldsUpdate `json:"by_tag,omitempty"`
// ExcludeIDs 在ByTag操作中需要排除的ID列表
ExcludeIDs []string `json:"exclude_ids,omitempty"`
// ByID 按条目ID更新,key为条目ID (seq_id)
ByID map[int64]FAQEntryFieldsUpdate `json:"by_id,omitempty"`
// ByTag 按Tag批量更新,key为TagID (seq_id)
ByTag map[int64]FAQEntryFieldsUpdate `json:"by_tag,omitempty"`
// ExcludeIDs 在ByTag操作中需要排除的ID列表 (seq_id)
ExcludeIDs []int64 `json:"exclude_ids,omitempty"`
}
// FAQImportTaskStatus 导入任务状态
@@ -332,22 +335,22 @@ type FAQImportMetadata struct {
// 这个信息是持久化的,不跟随进度状态,直到下次导入时被替换
type FAQImportResult struct {
// 导入统计信息
TotalEntries int `json:"total_entries"` // 总条目数
SuccessCount int `json:"success_count"` // 成功导入的条目数
FailedCount int `json:"failed_count"` // 失败的条目数
SkippedCount int `json:"skipped_count"` // 跳过的条目数(如重复等)
TotalEntries int `json:"total_entries"` // 总条目数
SuccessCount int `json:"success_count"` // 成功导入的条目数
FailedCount int `json:"failed_count"` // 失败的条目数
SkippedCount int `json:"skipped_count"` // 跳过的条目数(如重复等)
// 导入模式和时间信息
ImportMode string `json:"import_mode"` // 导入模式:append 或 replace
ImportedAt time.Time `json:"imported_at"` // 导入完成时间
TaskID string `json:"task_id"` // 导入任务ID
ImportMode string `json:"import_mode"` // 导入模式:append 或 replace
ImportedAt time.Time `json:"imported_at"` // 导入完成时间
TaskID string `json:"task_id"` // 导入任务ID
// 失败详情URL(失败条目较多时提供下载链接)
FailedEntriesURL string `json:"failed_entries_url,omitempty"` // 失败条目CSV下载URL
// 显示控制
DisplayStatus string `json:"display_status"` // 显示状态:open 或 close
// 额外统计信息
ProcessingTime int64 `json:"processing_time"` // 处理耗时(毫秒)
}
+4
View File
@@ -12,8 +12,12 @@ type ChunkRepository interface {
CreateChunks(ctx context.Context, chunks []*types.Chunk) error
// GetChunkByID gets a chunk by id
GetChunkByID(ctx context.Context, tenantID uint64, id string) (*types.Chunk, error)
// GetChunkBySeqID gets a chunk by seq_id
GetChunkBySeqID(ctx context.Context, tenantID uint64, seqID int64) (*types.Chunk, error)
// ListChunksByID lists chunks by ids
ListChunksByID(ctx context.Context, tenantID uint64, ids []string) ([]*types.Chunk, error)
// ListChunksBySeqID lists chunks by seq_ids
ListChunksBySeqID(ctx context.Context, tenantID uint64, seqIDs []int64) ([]*types.Chunk, error)
// ListChunksByKnowledgeID lists chunks by knowledge id
ListChunksByKnowledgeID(ctx context.Context, tenantID uint64, knowledgeID string) ([]*types.Chunk, error)
// ListPagedChunksByKnowledgeID lists paged chunks by knowledge id.
+9 -8
View File
@@ -73,14 +73,14 @@ type KnowledgeService interface {
// UpdateImageInfo updates image information for a knowledge chunk.
UpdateImageInfo(ctx context.Context, knowledgeID string, chunkID string, imageInfo string) error
// ListFAQEntries lists FAQ entries under a FAQ knowledge base.
// When tagID is non-empty, results are filtered by tag_id on FAQ chunks.
// When tagSeqID is non-zero, results are filtered by tag seq_id on FAQ chunks.
// searchField: specifies which field to search in ("standard_question", "similar_questions", "answers", "" for all)
// sortOrder: "asc" for time ascending (updated_at ASC), default is time descending (updated_at DESC)
ListFAQEntries(
ctx context.Context,
kbID string,
page *types.Pagination,
tagID string,
tagSeqID int64,
keyword string,
searchField string,
sortOrder string,
@@ -91,15 +91,15 @@ type KnowledgeService interface {
UpsertFAQEntries(ctx context.Context, kbID string, payload *types.FAQBatchUpsertPayload) (string, error)
// CreateFAQEntry creates a single FAQ entry synchronously.
CreateFAQEntry(ctx context.Context, kbID string, payload *types.FAQEntryPayload) (*types.FAQEntry, error)
// GetFAQEntry retrieves a single FAQ entry by ID.
GetFAQEntry(ctx context.Context, kbID string, entryID string) (*types.FAQEntry, error)
// GetFAQEntry retrieves a single FAQ entry by seq_id.
GetFAQEntry(ctx context.Context, kbID string, entrySeqID int64) (*types.FAQEntry, error)
// UpdateFAQEntry updates a single FAQ entry.
UpdateFAQEntry(ctx context.Context, kbID string, entryID string, payload *types.FAQEntryPayload) (*types.FAQEntry, error)
UpdateFAQEntry(ctx context.Context, kbID string, entrySeqID int64, payload *types.FAQEntryPayload) (*types.FAQEntry, error)
// UpdateFAQEntryFieldsBatch updates multiple fields for FAQ entries in batch.
// Supports updating is_enabled, is_recommended, tag_id, and other fields in a single call.
UpdateFAQEntryFieldsBatch(ctx context.Context, kbID string, req *types.FAQEntryFieldsBatchUpdate) error
// DeleteFAQEntries deletes FAQ entries in batch.
DeleteFAQEntries(ctx context.Context, kbID string, entryIDs []string) error
// DeleteFAQEntries deletes FAQ entries in batch by seq_id.
DeleteFAQEntries(ctx context.Context, kbID string, entrySeqIDs []int64) error
// SearchFAQEntries searches FAQ entries using hybrid search.
SearchFAQEntries(ctx context.Context, kbID string, req *types.FAQSearchRequest) ([]*types.FAQEntry, error)
// ExportFAQEntries exports all FAQ entries for a knowledge base as CSV data.
@@ -107,7 +107,8 @@ type KnowledgeService interface {
// UpdateKnowledgeTagBatch updates tag for document knowledge items in batch.
UpdateKnowledgeTagBatch(ctx context.Context, updates map[string]*string) error
// UpdateFAQEntryTagBatch updates tag for FAQ entries in batch.
UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[string]*string) error
// Key: entry seq_id, Value: tag seq_id (nil to remove tag)
UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[int64]*int64) error
// GetRepository gets the knowledge repository
GetRepository() KnowledgeRepository
// ProcessDocument handles Asynq document processing tasks
+4
View File
@@ -30,8 +30,12 @@ type KnowledgeTagRepository interface {
Create(ctx context.Context, tag *types.KnowledgeTag) error
Update(ctx context.Context, tag *types.KnowledgeTag) error
GetByID(ctx context.Context, tenantID uint64, id string) (*types.KnowledgeTag, error)
// GetBySeqID retrieves a tag by its seq_id.
GetBySeqID(ctx context.Context, tenantID uint64, seqID int64) (*types.KnowledgeTag, error)
// GetByIDs retrieves multiple tags by their IDs in a single query.
GetByIDs(ctx context.Context, tenantID uint64, ids []string) ([]*types.KnowledgeTag, error)
// GetBySeqIDs retrieves multiple tags by their seq_ids in a single query.
GetBySeqIDs(ctx context.Context, tenantID uint64, seqIDs []int64) ([]*types.KnowledgeTag, error)
GetByName(ctx context.Context, tenantID uint64, kbID string, name string) (*types.KnowledgeTag, error)
ListByKB(
ctx context.Context,
+2
View File
@@ -8,6 +8,8 @@ import "time"
type KnowledgeTag struct {
// Unique identifier of the tag (UUID)
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
// SeqID is an auto-increment integer ID for external API usage
SeqID int64 `json:"seq_id" gorm:"type:bigint;uniqueIndex"`
// Tenant ID
TenantID uint64 `json:"tenant_id"`
// Knowledge base ID that this tag belongs to
@@ -0,0 +1,5 @@
-- Migration 000010: Remove seq_id from chunks and knowledge_tags tables
-- MySQL version
ALTER TABLE chunks DROP COLUMN seq_id;
ALTER TABLE knowledge_tags DROP COLUMN seq_id;
+29
View File
@@ -0,0 +1,29 @@
-- Migration 000010: Add seq_id (auto-increment integer ID) to chunks and knowledge_tags tables
-- This provides integer IDs for FAQ entries and tags for external API usage
-- MySQL version
-- ============================================================================
-- Section 1: Add seq_id to chunks table
-- ============================================================================
-- Add seq_id column with AUTO_INCREMENT (historical data starts from 1)
ALTER TABLE chunks ADD COLUMN seq_id BIGINT NOT NULL AUTO_INCREMENT UNIQUE KEY;
-- Update historical data to start from 100000000
UPDATE chunks SET seq_id = seq_id + 99999999;
-- Set AUTO_INCREMENT for future inserts (must be greater than max seq_id)
ALTER TABLE chunks AUTO_INCREMENT = 200000000;
-- ============================================================================
-- Section 2: Add seq_id to knowledge_tags table
-- ============================================================================
-- Add seq_id column with AUTO_INCREMENT (historical data starts from 1)
ALTER TABLE knowledge_tags ADD COLUMN seq_id BIGINT NOT NULL AUTO_INCREMENT UNIQUE KEY;
-- Update historical data to start from 10000000
UPDATE knowledge_tags SET seq_id = seq_id + 9999999;
-- Set AUTO_INCREMENT for future inserts (must be greater than max seq_id)
ALTER TABLE knowledge_tags AUTO_INCREMENT = 20000000;
@@ -0,0 +1,11 @@
-- Migration 000010 Down: Remove seq_id from chunks and knowledge_tags tables
-- Remove seq_id from chunks
DROP INDEX IF EXISTS idx_chunks_seq_id;
ALTER TABLE chunks DROP COLUMN IF EXISTS seq_id;
DROP SEQUENCE IF EXISTS chunks_seq_id_seq;
-- Remove seq_id from knowledge_tags
DROP INDEX IF EXISTS idx_knowledge_tags_seq_id;
ALTER TABLE knowledge_tags DROP COLUMN IF EXISTS seq_id;
DROP SEQUENCE IF EXISTS knowledge_tags_seq_id_seq;
@@ -0,0 +1,52 @@
-- Migration 000010: Add seq_id (auto-increment integer ID) to chunks and knowledge_tags tables
-- This provides integer IDs for FAQ entries and tags for external API usage
-- ============================================================================
-- Section 1: Add seq_id to chunks table
-- ============================================================================
DO $$ BEGIN RAISE NOTICE '[Migration 000010] Adding seq_id column to chunks table'; END $$;
-- Create sequence for chunks with starting value > 72528124
CREATE SEQUENCE IF NOT EXISTS chunks_seq_id_seq START WITH 100000000;
-- Add seq_id column to chunks table
ALTER TABLE chunks ADD COLUMN IF NOT EXISTS seq_id BIGINT;
-- Set default value using sequence
ALTER TABLE chunks ALTER COLUMN seq_id SET DEFAULT nextval('chunks_seq_id_seq');
-- Populate existing rows with sequence values
UPDATE chunks SET seq_id = nextval('chunks_seq_id_seq') WHERE seq_id IS NULL;
-- Make seq_id NOT NULL after populating
ALTER TABLE chunks ALTER COLUMN seq_id SET NOT NULL;
-- Create unique index on seq_id
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_seq_id ON chunks(seq_id);
-- ============================================================================
-- Section 2: Add seq_id to knowledge_tags table
-- ============================================================================
DO $$ BEGIN RAISE NOTICE '[Migration 000010] Adding seq_id column to knowledge_tags table'; END $$;
-- Create sequence for knowledge_tags with starting value > 2924026
CREATE SEQUENCE IF NOT EXISTS knowledge_tags_seq_id_seq START WITH 10000000;
-- Add seq_id column to knowledge_tags table
ALTER TABLE knowledge_tags ADD COLUMN IF NOT EXISTS seq_id BIGINT;
-- Set default value using sequence
ALTER TABLE knowledge_tags ALTER COLUMN seq_id SET DEFAULT nextval('knowledge_tags_seq_id_seq');
-- Populate existing rows with sequence values
UPDATE knowledge_tags SET seq_id = nextval('knowledge_tags_seq_id_seq') WHERE seq_id IS NULL;
-- Make seq_id NOT NULL after populating
ALTER TABLE knowledge_tags ALTER COLUMN seq_id SET NOT NULL;
-- Create unique index on seq_id
CREATE UNIQUE INDEX IF NOT EXISTS idx_knowledge_tags_seq_id ON knowledge_tags(seq_id);
DO $$ BEGIN RAISE NOTICE '[Migration 000010] seq_id columns added successfully!'; END $$;