mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
feat: 为 FAQ 和标签引入 seq_id 支持
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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"` // 处理耗时(毫秒)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 $$;
|
||||
Reference in New Issue
Block a user