mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-21 05:43:43 +08:00
feat: 新增FAQ条目导出功能,支持CSV格式导出
This commit is contained in:
@@ -3,6 +3,7 @@ package client
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -171,6 +172,28 @@ func (c *Client) ListFAQEntries(ctx context.Context,
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// ExportFAQEntries exports all FAQ entries from a knowledge base as CSV data.
|
||||
// The CSV format matches the import example format with 8 columns:
|
||||
// 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔),
|
||||
// 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE),
|
||||
// 是否禁止被推荐(选填-默认False 可被推荐)
|
||||
func (c *Client) ExportFAQEntries(ctx context.Context, knowledgeBaseID string) ([]byte, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/export", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the raw CSV data from response body
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read export response: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// UpsertFAQEntries imports or appends FAQ entries asynchronously and returns the task ID.
|
||||
func (c *Client) UpsertFAQEntries(ctx context.Context,
|
||||
knowledgeBaseID string, payload *FAQBatchUpsertPayload,
|
||||
@@ -208,6 +231,28 @@ func (c *Client) CreateFAQEntry(ctx context.Context,
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// ExportFAQEntries exports all FAQ entries from a knowledge base as CSV data.
|
||||
// The CSV format matches the import example format with 8 columns:
|
||||
// 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔),
|
||||
// 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE),
|
||||
// 是否禁止被推荐(选填-默认False 可被推荐)
|
||||
func (c *Client) ExportFAQEntries(ctx context.Context, knowledgeBaseID string) ([]byte, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/export", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the raw CSV data from response body
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read export response: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// UpdateFAQEntry updates a single FAQ entry.
|
||||
func (c *Client) UpdateFAQEntry(ctx context.Context,
|
||||
knowledgeBaseID, entryID string, payload *FAQEntryPayload,
|
||||
@@ -285,3 +330,25 @@ func (c *Client) SearchFAQEntries(ctx context.Context,
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// ExportFAQEntries exports all FAQ entries from a knowledge base as CSV data.
|
||||
// The CSV format matches the import example format with 8 columns:
|
||||
// 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔),
|
||||
// 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE),
|
||||
// 是否禁止被推荐(选填-默认False 可被推荐)
|
||||
func (c *Client) ExportFAQEntries(ctx context.Context, knowledgeBaseID string) ([]byte, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/export", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the raw CSV data from response body
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read export response: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -205,4 +205,10 @@ export function searchFAQEntries(
|
||||
}
|
||||
) {
|
||||
return post(`/api/v1/knowledge-bases/${kbId}/faq/search`, data);
|
||||
}
|
||||
|
||||
// Export FAQ entries as CSV file
|
||||
export async function exportFAQEntries(kbId: string): Promise<Blob> {
|
||||
const response = await getDown(`/api/v1/knowledge-bases/${kbId}/faq/entries/export`);
|
||||
return response as unknown as Blob;
|
||||
}
|
||||
@@ -99,6 +99,16 @@
|
||||
<span class="kb-action-title">{{ t('knowledgeEditor.faq.searchTest') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu_item kb-action-item" @click.stop="handleFaqExportFromMenu">
|
||||
<div class="kb-action-icon-wrapper">
|
||||
<svg class="kb-action-icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path d="M15.75 11.25V14.25C15.75 14.6478 15.592 15.0294 15.3107 15.3107C15.0294 15.592 14.6478 15.75 14.25 15.75H3.75C3.35218 15.75 2.97064 15.592 2.68934 15.3107C2.40804 15.0294 2.25 14.6478 2.25 14.25V11.25M12.75 6L9 2.25M9 2.25L5.25 6M9 2.25V11.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="kb-action-content">
|
||||
<span class="kb-action-title">{{ t('knowledgeEditor.faqExport.exportButton') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<t-dropdown
|
||||
v-if="selectedFaqCount > 0"
|
||||
:options="faqBatchActionOptions"
|
||||
@@ -1132,7 +1142,7 @@ const handleDocURLImport = async () => {
|
||||
}))
|
||||
}
|
||||
|
||||
const dispatchFaqMenuAction = (action: 'create' | 'import' | 'search' | 'batch' | 'batchTag' | 'batchEnable' | 'batchDisable' | 'batchDelete', kbId: string) => {
|
||||
const dispatchFaqMenuAction = (action: 'create' | 'import' | 'search' | 'export' | 'batch' | 'batchTag' | 'batchEnable' | 'batchDisable' | 'batchDelete', kbId: string) => {
|
||||
window.dispatchEvent(new CustomEvent('faqMenuAction', {
|
||||
detail: { action, kbId }
|
||||
}))
|
||||
@@ -1165,6 +1175,15 @@ const handleFaqSearchTestFromMenu = async () => {
|
||||
dispatchFaqMenuAction('search', kbId)
|
||||
}
|
||||
|
||||
const handleFaqExportFromMenu = async () => {
|
||||
const kbId = await getCurrentKbId()
|
||||
if (!kbId) {
|
||||
MessagePlugin.warning(t('knowledgeEditor.messages.missingId'))
|
||||
return
|
||||
}
|
||||
dispatchFaqMenuAction('export', kbId)
|
||||
}
|
||||
|
||||
const faqBatchActionOptions = computed(() => {
|
||||
if (selectedFaqCount.value === 0) {
|
||||
return []
|
||||
|
||||
@@ -984,6 +984,11 @@ export default {
|
||||
downloadExampleCSV: 'Download CSV Example',
|
||||
downloadExampleExcel: 'Download Excel Example',
|
||||
},
|
||||
faqExport: {
|
||||
exportButton: 'Export CSV',
|
||||
exportSuccess: 'Export successful',
|
||||
exportFailed: 'Export failed',
|
||||
},
|
||||
models: {
|
||||
title: 'Model Configuration',
|
||||
description: 'Select appropriate AI models for the knowledge base',
|
||||
|
||||
@@ -1081,6 +1081,11 @@ export default {
|
||||
downloadExampleCSV: 'Скачать пример CSV',
|
||||
downloadExampleExcel: 'Скачать пример Excel',
|
||||
},
|
||||
faqExport: {
|
||||
exportButton: 'Экспорт CSV',
|
||||
exportSuccess: 'Экспорт успешен',
|
||||
exportFailed: 'Ошибка экспорта',
|
||||
},
|
||||
models: {
|
||||
title: 'Конфигурация моделей',
|
||||
description: 'Выберите подходящие AI-модели для базы знаний',
|
||||
|
||||
@@ -1320,6 +1320,11 @@ export default {
|
||||
downloadExampleCSV: "下载 CSV 示例",
|
||||
downloadExampleExcel: "下载 Excel 示例",
|
||||
},
|
||||
faqExport: {
|
||||
exportButton: "导出 CSV",
|
||||
exportSuccess: "导出成功",
|
||||
exportFailed: "导出失败",
|
||||
},
|
||||
models: {
|
||||
title: "模型配置",
|
||||
description: "为知识库选择合适的 AI 模型",
|
||||
|
||||
@@ -1926,6 +1926,7 @@ const handleAddToKnowledge = (answerEvent: any) => {
|
||||
max-height: 300px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-height: 100px; /* 防止流式输出时图片高度塌陷导致抖动 */
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
@@ -1933,6 +1934,7 @@ const handleAddToKnowledge = (answerEvent: any) => {
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
background-color: #f9fafb; /* 加载时的占位背景色 */
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
@@ -2058,6 +2060,7 @@ const handleAddToKnowledge = (answerEvent: any) => {
|
||||
max-height: 300px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-height: 100px; /* 防止流式输出时图片高度塌陷导致抖动 */
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
@@ -2065,6 +2068,7 @@ const handleAddToKnowledge = (answerEvent: any) => {
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
background-color: #f9fafb; /* 加载时的占位背景色 */
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
|
||||
@@ -142,14 +142,18 @@
|
||||
<div ref="tagListRef" class="faq-tag-list" @scroll="handleTagListScroll">
|
||||
<div
|
||||
class="faq-tag-item"
|
||||
:class="{ active: selectedTagId === '' }"
|
||||
:class="{ active: selectedTagId === UNTAGGED_FILTER }"
|
||||
@click="handleUntaggedClick"
|
||||
>
|
||||
<div class="faq-tag-left">
|
||||
<t-icon name="folder" size="18px" />
|
||||
<span>{{ $t('knowledgeBase.untagged') }}</span>
|
||||
</div>
|
||||
<span class="faq-tag-count">{{ untaggedFAQCount }}</span>
|
||||
<div class="faq-tag-right">
|
||||
<span class="faq-tag-count">{{ untaggedFAQCount }}</span>
|
||||
<!-- Placeholder for alignment with other tags that have more menu -->
|
||||
<div class="tag-more-placeholder"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="creatingTag" class="faq-tag-item tag-editing" @click.stop>
|
||||
@@ -1111,6 +1115,7 @@ import {
|
||||
updateFAQEntryFieldsBatch,
|
||||
deleteFAQEntries,
|
||||
searchFAQEntries,
|
||||
exportFAQEntries,
|
||||
listKnowledgeTags,
|
||||
updateFAQEntryTagBatch,
|
||||
createKnowledgeBaseTag,
|
||||
@@ -1184,7 +1189,9 @@ type TagInputInstance = ComponentPublicInstance<{ focus: () => void; select: ()
|
||||
const tagList = ref<any[]>([])
|
||||
const tagLoading = ref(false)
|
||||
const tagListRef = ref<HTMLElement | null>(null)
|
||||
const selectedTagId = ref<string>('')
|
||||
// Special value to represent "untagged" filter - must match backend constant
|
||||
const UNTAGGED_FILTER = '__untagged__'
|
||||
const selectedTagId = ref<string>(UNTAGGED_FILTER)
|
||||
const overallFAQTotal = ref(0)
|
||||
const tagSearchQuery = ref('')
|
||||
const TAG_PAGE_SIZE = 20
|
||||
@@ -1328,87 +1335,6 @@ const searchForm = reactive({
|
||||
matchCount: 10,
|
||||
})
|
||||
|
||||
// Toolbar actions dropdown
|
||||
const toolbarActionOptions = computed(() => {
|
||||
const options = [
|
||||
{
|
||||
content: t('knowledgeEditor.faqImport.importButton'),
|
||||
value: 'import',
|
||||
icon: 'upload',
|
||||
disabled: importState.taskStatus?.status === 'running' // 导入过程中禁用导入按钮
|
||||
},
|
||||
{ content: t('knowledgeEditor.faq.searchTest'), value: 'search', icon: 'search' },
|
||||
]
|
||||
|
||||
// 如果有选中的条目,添加批量操作选项
|
||||
if (selectedRowKeys.value.length > 0) {
|
||||
options.push(
|
||||
{
|
||||
content: `${t('knowledgeEditor.faq.batchUpdateTag')} (${selectedRowKeys.value.length})`,
|
||||
value: 'batchTag',
|
||||
icon: 'folder',
|
||||
},
|
||||
{
|
||||
content: `${t('knowledgeEditor.faq.batchEnable')} (${selectedRowKeys.value.length})`,
|
||||
value: 'batchEnable',
|
||||
icon: 'check-circle',
|
||||
},
|
||||
{
|
||||
content: `${t('knowledgeEditor.faq.batchDisable')} (${selectedRowKeys.value.length})`,
|
||||
value: 'batchDisable',
|
||||
icon: 'close-circle',
|
||||
},
|
||||
/* 暂时隐藏推荐批量操作
|
||||
{
|
||||
content: `${t('knowledgeEditor.faq.batchEnableRecommended')} (${selectedRowKeys.value.length})`,
|
||||
value: 'batchEnableRecommended',
|
||||
icon: 'thumb-up',
|
||||
},
|
||||
{
|
||||
content: `${t('knowledgeEditor.faq.batchDisableRecommended')} (${selectedRowKeys.value.length})`,
|
||||
value: 'batchDisableRecommended',
|
||||
icon: 'thumb-down',
|
||||
},
|
||||
*/
|
||||
{
|
||||
content: `${t('knowledgeEditor.faqImport.deleteSelected')} (${selectedRowKeys.value.length})`,
|
||||
value: 'delete',
|
||||
icon: 'delete',
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const handleToolbarAction = (data: { value: string }) => {
|
||||
switch (data.value) {
|
||||
case 'import':
|
||||
openImportDialog()
|
||||
break
|
||||
case 'search':
|
||||
searchDrawerVisible.value = true
|
||||
break
|
||||
case 'batchTag':
|
||||
openBatchTagDialog()
|
||||
break
|
||||
case 'batchEnable':
|
||||
handleBatchStatusChange(true)
|
||||
break
|
||||
case 'batchDisable':
|
||||
handleBatchStatusChange(false)
|
||||
break
|
||||
case 'batchEnableRecommended':
|
||||
handleBatchRecommendedChange(true)
|
||||
break
|
||||
case 'batchDisableRecommended':
|
||||
handleBatchRecommendedChange(false)
|
||||
break
|
||||
case 'delete':
|
||||
handleBatchDelete()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 标签列表滚动加载更多
|
||||
const handleTagListScroll = () => {
|
||||
@@ -1507,8 +1433,8 @@ const handleUntaggedClick = () => {
|
||||
if (editingTagId.value) {
|
||||
cancelEditTag()
|
||||
}
|
||||
if (selectedTagId.value === '') return
|
||||
handleTagFilterChange('')
|
||||
if (selectedTagId.value === UNTAGGED_FILTER) return
|
||||
handleTagFilterChange(UNTAGGED_FILTER)
|
||||
}
|
||||
|
||||
const startCreateTag = () => {
|
||||
@@ -1618,7 +1544,7 @@ const confirmDeleteTag = (tag: any) => {
|
||||
await deleteKnowledgeBaseTag(props.kbId, tag.id, { force: true })
|
||||
MessagePlugin.success(t('knowledgeBase.tagDeleteSuccess'))
|
||||
if (selectedTagId.value === tag.id) {
|
||||
handleTagFilterChange('')
|
||||
handleTagFilterChange(UNTAGGED_FILTER)
|
||||
}
|
||||
await loadTags()
|
||||
await loadEntries()
|
||||
@@ -1682,6 +1608,8 @@ const handleFaqMenuAction = (event: Event) => {
|
||||
openImportDialog()
|
||||
} else if (detail.action === 'search') {
|
||||
searchDrawerVisible.value = true
|
||||
} else if (detail.action === 'export') {
|
||||
handleExportCSV()
|
||||
} else if (detail.action === 'batch') {
|
||||
// 批量操作通过左侧菜单的下拉菜单处理
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
@@ -1788,6 +1716,16 @@ const loadEntries = async (append = false) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// If overallFAQTotal is not initialized, fetch it first (without tag_id filter)
|
||||
if (overallFAQTotal.value === 0 && !append) {
|
||||
const totalRes = await listFAQEntries(props.kbId, {
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
})
|
||||
const totalData = (totalRes.data || {}) as { total: number }
|
||||
overallFAQTotal.value = totalData.total || 0
|
||||
}
|
||||
|
||||
const res = await listFAQEntries(props.kbId, {
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
@@ -1813,9 +1751,6 @@ const loadEntries = async (append = false) => {
|
||||
} else {
|
||||
entries.value = newEntries
|
||||
}
|
||||
if (selectedTagId.value === '') {
|
||||
overallFAQTotal.value = pageData.total || 0
|
||||
}
|
||||
// 判断是否还有更多数据
|
||||
hasMore.value = entries.value.length < (pageData.total || 0)
|
||||
currentPage++
|
||||
@@ -2328,8 +2263,9 @@ const startPolling = (taskId: string) => {
|
||||
if (status === 'success') {
|
||||
MessagePlugin.success(t('knowledgeEditor.faqImport.importSuccess'))
|
||||
// 清除筛选条件,确保用户能看到所有新导入的数据
|
||||
selectedTagId.value = ''
|
||||
selectedTagId.value = UNTAGGED_FILTER
|
||||
entrySearchKeyword.value = ''
|
||||
overallFAQTotal.value = 0 // Reset to trigger re-fetch
|
||||
await loadEntries()
|
||||
await loadTags()
|
||||
// 任务完成后,3秒后自动关闭进度条
|
||||
@@ -2653,12 +2589,41 @@ const downloadExcelExample = () => {
|
||||
XLSX.writeFile(workbook, 'faq_example.xlsx')
|
||||
}
|
||||
|
||||
// 导出 FAQ 数据为 CSV
|
||||
const exportLoading = ref(false)
|
||||
const handleExportCSV = async () => {
|
||||
if (!props.kbId) {
|
||||
MessagePlugin.warning(t('knowledgeBase.selectKnowledgeBase') || '请先选择知识库')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
try {
|
||||
const blob = await exportFAQEntries(props.kbId)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `faq_export_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
MessagePlugin.success(t('knowledgeEditor.faqExport.exportSuccess') || '导出成功')
|
||||
} catch (error: any) {
|
||||
console.error('Export failed:', error)
|
||||
MessagePlugin.error(t('knowledgeEditor.faqExport.exportFailed') || '导出失败')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.kbId,
|
||||
async (newKbId) => {
|
||||
currentPage = 1
|
||||
hasMore.value = true
|
||||
selectedTagId.value = ''
|
||||
selectedTagId.value = UNTAGGED_FILTER
|
||||
overallFAQTotal.value = 0 // Reset to trigger re-fetch
|
||||
cancelCreateTag()
|
||||
cancelEditTag()
|
||||
tagSearchQuery.value = ''
|
||||
@@ -3375,6 +3340,12 @@ watch(() => entries.value.map(e => ({
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-more-placeholder {
|
||||
width: 24px; // Same width as tag-more-btn
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-empty-state {
|
||||
|
||||
@@ -86,7 +86,10 @@ func (r *chunkRepository) ListPagedChunksByKnowledgeID(
|
||||
baseFilter := func(db *gorm.DB) *gorm.DB {
|
||||
db = db.Where("tenant_id = ? AND knowledge_id = ? AND chunk_type IN (?) AND status in (?)",
|
||||
tenantID, knowledgeID, chunkType, []int{int(types.ChunkStatusIndexed), int(types.ChunkStatusDefault)})
|
||||
if tagID != "" {
|
||||
if tagID == "__untagged__" {
|
||||
// Special value to filter entries without a tag
|
||||
db = db.Where("tag_id = ''")
|
||||
} else if tagID != "" {
|
||||
db = db.Where("tag_id = ?", tagID)
|
||||
}
|
||||
if keyword != "" {
|
||||
@@ -389,6 +392,48 @@ func (r *chunkRepository) ListAllFAQChunksWithMetadataByKnowledgeBaseID(
|
||||
return allChunks, nil
|
||||
}
|
||||
|
||||
// ListAllFAQChunksForExport lists all FAQ chunks for export with full metadata, tag_id, is_enabled, and flags.
|
||||
// Uses batch query to handle large datasets.
|
||||
func (r *chunkRepository) ListAllFAQChunksForExport(
|
||||
ctx context.Context,
|
||||
tenantID uint64,
|
||||
knowledgeID string,
|
||||
) ([]*types.Chunk, error) {
|
||||
const batchSize = 1000 // 每批查询1000条
|
||||
var allChunks []*types.Chunk
|
||||
offset := 0
|
||||
|
||||
for {
|
||||
var batchChunks []*types.Chunk
|
||||
if err := r.db.WithContext(ctx).
|
||||
Select("id, metadata, tag_id, is_enabled, flags").
|
||||
Where("tenant_id = ? AND knowledge_id = ? AND chunk_type = ? AND status = ?",
|
||||
tenantID, knowledgeID, types.ChunkTypeFAQ, types.ChunkStatusIndexed).
|
||||
Order("created_at ASC").
|
||||
Offset(offset).
|
||||
Limit(batchSize).
|
||||
Find(&batchChunks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果没有查询到数据,说明已经查询完毕
|
||||
if len(batchChunks) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
allChunks = append(allChunks, batchChunks...)
|
||||
|
||||
// 如果返回的数据少于批次大小,说明已经是最后一批
|
||||
if len(batchChunks) < batchSize {
|
||||
break
|
||||
}
|
||||
|
||||
offset += batchSize
|
||||
}
|
||||
|
||||
return allChunks, nil
|
||||
}
|
||||
|
||||
// UpdateChunkFlagsBatch updates flags for multiple chunks in batch using SQL CASE expressions.
|
||||
// This is more efficient than updating chunks one by one.
|
||||
// setFlags: map of chunk ID to flags to set (OR operation)
|
||||
|
||||
@@ -3895,6 +3895,128 @@ func (s *knowledgeService) DeleteFAQEntries(ctx context.Context,
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportFAQEntries exports all FAQ entries for a knowledge base as CSV data.
|
||||
// The CSV format matches the import example format with 8 columns:
|
||||
// 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔),
|
||||
// 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE),
|
||||
// 是否禁止被推荐(选填-默认False 可被推荐)
|
||||
func (s *knowledgeService) ExportFAQEntries(ctx context.Context, kbID string) ([]byte, error) {
|
||||
kb, err := s.validateFAQKnowledgeBase(ctx, kbID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tenantID := ctx.Value(types.TenantIDContextKey).(uint64)
|
||||
faqKnowledge, err := s.findFAQKnowledge(ctx, tenantID, kb.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if faqKnowledge == nil {
|
||||
// Return empty CSV with headers only
|
||||
return s.buildFAQCSV(nil, nil), nil
|
||||
}
|
||||
|
||||
// Get all FAQ chunks
|
||||
chunks, err := s.chunkRepo.ListAllFAQChunksForExport(ctx, tenantID, faqKnowledge.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list FAQ chunks: %w", err)
|
||||
}
|
||||
|
||||
// Build tag map for tag_id -> tag_name conversion
|
||||
tagMap, err := s.buildTagMap(ctx, tenantID, kbID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build tag map: %w", err)
|
||||
}
|
||||
|
||||
return s.buildFAQCSV(chunks, tagMap), nil
|
||||
}
|
||||
|
||||
// buildTagMap builds a map from tag_id to tag_name for the given knowledge base.
|
||||
func (s *knowledgeService) buildTagMap(ctx context.Context, tenantID uint64, kbID string) (map[string]string, error) {
|
||||
// Get all tags for this knowledge base (no pagination limit)
|
||||
page := &types.Pagination{Page: 1, PageSize: 10000}
|
||||
tags, _, err := s.tagRepo.ListByKB(ctx, tenantID, kbID, page, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tagMap := make(map[string]string, len(tags))
|
||||
for _, tag := range tags {
|
||||
if tag != nil {
|
||||
tagMap[tag.ID] = tag.Name
|
||||
}
|
||||
}
|
||||
return tagMap, nil
|
||||
}
|
||||
|
||||
// buildFAQCSV builds CSV content from FAQ chunks.
|
||||
func (s *knowledgeService) buildFAQCSV(chunks []*types.Chunk, tagMap map[string]string) []byte {
|
||||
var buf strings.Builder
|
||||
|
||||
// Write CSV header (matching import example format)
|
||||
headers := []string{
|
||||
"分类(必填)",
|
||||
"问题(必填)",
|
||||
"相似问题(选填-多个用##分隔)",
|
||||
"反例问题(选填-多个用##分隔)",
|
||||
"机器人回答(必填-多个用##分隔)",
|
||||
"是否全部回复(选填-默认FALSE)",
|
||||
"是否停用(选填-默认FALSE)",
|
||||
"是否禁止被推荐(选填-默认False 可被推荐)",
|
||||
}
|
||||
buf.WriteString(strings.Join(headers, ","))
|
||||
buf.WriteString("\n")
|
||||
|
||||
// Write data rows
|
||||
for _, chunk := range chunks {
|
||||
meta, err := chunk.FAQMetadata()
|
||||
if err != nil || meta == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get tag name
|
||||
tagName := ""
|
||||
if chunk.TagID != "" && tagMap != nil {
|
||||
if name, ok := tagMap[chunk.TagID]; ok {
|
||||
tagName = name
|
||||
}
|
||||
}
|
||||
|
||||
// Build row
|
||||
row := []string{
|
||||
escapeCSVField(tagName),
|
||||
escapeCSVField(meta.StandardQuestion),
|
||||
escapeCSVField(strings.Join(meta.SimilarQuestions, "##")),
|
||||
escapeCSVField(strings.Join(meta.NegativeQuestions, "##")),
|
||||
escapeCSVField(strings.Join(meta.Answers, "##")),
|
||||
boolToCSV(meta.AnswerStrategy == types.AnswerStrategyAll),
|
||||
boolToCSV(!chunk.IsEnabled), // 是否停用:取反
|
||||
boolToCSV(!chunk.Flags.HasFlag(types.ChunkFlagRecommended)), // 是否禁止被推荐:取反
|
||||
}
|
||||
buf.WriteString(strings.Join(row, ","))
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
return []byte(buf.String())
|
||||
}
|
||||
|
||||
// escapeCSVField escapes a field for CSV format.
|
||||
func escapeCSVField(field string) string {
|
||||
// If field contains comma, newline, or quote, wrap in quotes and escape internal quotes
|
||||
if strings.ContainsAny(field, ",\"\n\r") {
|
||||
return "\"" + strings.ReplaceAll(field, "\"", "\"\"") + "\""
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
// boolToCSV converts a boolean to CSV TRUE/FALSE string.
|
||||
func boolToCSV(b bool) string {
|
||||
if b {
|
||||
return "TRUE"
|
||||
}
|
||||
return "FALSE"
|
||||
}
|
||||
|
||||
func (s *knowledgeService) validateFAQKnowledgeBase(ctx context.Context, kbID string) (*types.KnowledgeBase, error) {
|
||||
if kbID == "" {
|
||||
return nil, werrors.NewBadRequestError("知识库 ID 不能为空")
|
||||
|
||||
@@ -221,3 +221,23 @@ func (h *FAQHandler) SearchFAQ(c *gin.Context) {
|
||||
"data": entries,
|
||||
})
|
||||
}
|
||||
|
||||
// ExportEntries exports all FAQ entries as a CSV file.
|
||||
func (h *FAQHandler) ExportEntries(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
kbID := secutils.SanitizeForLog(c.Param("id"))
|
||||
|
||||
csvData, err := h.knowledgeService.ExportFAQEntries(ctx, kbID)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set response headers for CSV download
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename=faq_export.csv")
|
||||
// Add BOM for Excel compatibility with UTF-8
|
||||
bom := []byte{0xEF, 0xBB, 0xBF}
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", append(bom, csvData...))
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ func RegisterFAQRoutes(r *gin.RouterGroup, handler *handler.FAQHandler) {
|
||||
faq := r.Group("/knowledge-bases/:id/faq")
|
||||
{
|
||||
faq.GET("/entries", handler.ListEntries)
|
||||
faq.GET("/entries/export", handler.ExportEntries)
|
||||
faq.POST("/entries", handler.UpsertEntries)
|
||||
faq.POST("/entry", handler.CreateEntry)
|
||||
faq.PUT("/entries/:entry_id", handler.UpdateEntry)
|
||||
|
||||
@@ -52,6 +52,8 @@ type ChunkRepository interface {
|
||||
// ListAllFAQChunksWithMetadataByKnowledgeBaseID lists all FAQ chunks for a knowledge base ID
|
||||
// returns ID and Metadata fields for duplicate question checking
|
||||
ListAllFAQChunksWithMetadataByKnowledgeBaseID(ctx context.Context, tenantID uint64, kbID string) ([]*types.Chunk, error)
|
||||
// ListAllFAQChunksForExport lists all FAQ chunks for export with full metadata, tag_id, is_enabled, and flags
|
||||
ListAllFAQChunksForExport(ctx context.Context, tenantID uint64, knowledgeID string) ([]*types.Chunk, error)
|
||||
// UpdateChunkFlagsBatch updates flags for multiple chunks in batch using a single SQL statement.
|
||||
// setFlags: map of chunk ID to flags to set (OR operation)
|
||||
// clearFlags: map of chunk ID to flags to clear (AND NOT operation)
|
||||
|
||||
@@ -95,6 +95,8 @@ type KnowledgeService interface {
|
||||
DeleteFAQEntries(ctx context.Context, kbID string, entryIDs []string) 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.
|
||||
ExportFAQEntries(ctx context.Context, kbID string) ([]byte, error)
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user