feat: 新增FAQ条目导出功能,支持CSV格式导出

This commit is contained in:
wizardchen
2025-12-17 22:04:18 +08:00
committed by lyingbug
parent d9b04a19a6
commit ad6bd157fd
14 changed files with 368 additions and 94 deletions
+67
View File
@@ -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
}
+6
View File
@@ -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;
}
+20 -1
View File
@@ -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 []
+5
View File
@@ -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',
+5
View File
@@ -1081,6 +1081,11 @@ export default {
downloadExampleCSV: 'Скачать пример CSV',
downloadExampleExcel: 'Скачать пример Excel',
},
faqExport: {
exportButton: 'Экспорт CSV',
exportSuccess: 'Экспорт успешен',
exportFailed: 'Ошибка экспорта',
},
models: {
title: 'Конфигурация моделей',
description: 'Выберите подходящие AI-модели для базы знаний',
+5
View File
@@ -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 {
+46 -1
View File
@@ -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)
+122
View File
@@ -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 不能为空")
+20
View File
@@ -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...))
}
+1
View File
@@ -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)
+2
View File
@@ -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)
+2
View File
@@ -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.