refactor: Implement upload progress tracking in KnowledgeBaseList component and enhance error handling for file uploads

This commit is contained in:
wizardchen
2025-11-28 15:32:51 +08:00
parent 2e3a1fb9db
commit a318a08ab7
8 changed files with 465 additions and 50 deletions
+126 -39
View File
@@ -678,6 +678,35 @@ const handleDocUploadClick = async () => {
docUploadInput.value?.click()
}
const FAILED_FILES_PREVIEW_LIMIT = 10
const summarizeFailedFiles = (failedFiles: Array<{ name: string; reason: string }>) => {
const duplicateLabel = t('knowledgeBase.fileExists')
let duplicateCount = 0
const nonDuplicate: Array<{ name: string; reason: string }> = []
failedFiles.forEach((file) => {
if (file.reason === duplicateLabel) {
duplicateCount++
} else {
nonDuplicate.push(file)
}
})
const previewList = nonDuplicate.slice(0, FAILED_FILES_PREVIEW_LIMIT).map(f => `• ${f.name}: ${f.reason}`)
let nonDuplicateText = ''
if (previewList.length) {
nonDuplicateText = previewList.join('\n')
if (nonDuplicate.length > FAILED_FILES_PREVIEW_LIMIT) {
nonDuplicateText += `\n${t('knowledgeBase.andMoreFiles', { count: nonDuplicate.length - FAILED_FILES_PREVIEW_LIMIT })}`
}
}
return {
duplicateCount,
nonDuplicateText,
}
}
const handleDocFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
const files = input?.files
@@ -736,8 +765,7 @@ const handleDocFileChange = async (event: Event) => {
detail: {
kbId,
uploadId,
fileName: file.name,
file
fileName: file.name
}
}))
@@ -809,24 +837,35 @@ const handleDocFileChange = async (event: Event) => {
if (failCount === 0) {
MessagePlugin.success(t('knowledgeBase.uploadAllSuccess', { count: successCount }))
} else if (successCount > 0) {
// 部分成功,显示详细失败信息
const failedList = failedFiles.map(f => `• ${f.name}: ${f.reason}`).join('\n')
const { duplicateCount, nonDuplicateText } = summarizeFailedFiles(failedFiles)
const extraSections: string[] = []
if (duplicateCount > 0) {
extraSections.push(t('knowledgeBase.duplicateFilesSkipped', { count: duplicateCount }))
}
if (nonDuplicateText) {
extraSections.push(t('knowledgeBase.failedFilesList') + '\n' + nonDuplicateText)
}
const extraContent = extraSections.length ? '\n\n' + extraSections.join('\n\n') : ''
MessagePlugin.warning({
content: t('knowledgeBase.uploadPartialSuccess', {
success: successCount,
fail: failCount
}) + '\n\n' + t('knowledgeBase.failedFilesList') + '\n' + failedList,
}) + extraContent,
duration: 8000,
closeBtn: true
})
} else {
// 全部失败,显示详细失败信息
const failedList = failedFiles.slice(0, 5).map(f => `• ${f.name}: ${f.reason}`).join('\n')
const moreCount = failedFiles.length > 5 ? failedFiles.length - 5 : 0
const { duplicateCount, nonDuplicateText } = summarizeFailedFiles(failedFiles)
const extraSections: string[] = []
if (duplicateCount > 0) {
extraSections.push(t('knowledgeBase.duplicateFilesSkipped', { count: duplicateCount }))
}
if (nonDuplicateText) {
extraSections.push(t('knowledgeBase.failedFilesList') + '\n' + nonDuplicateText)
}
const extraContent = extraSections.length ? '\n\n' + extraSections.join('\n\n') : ''
MessagePlugin.error({
content: t('knowledgeBase.uploadAllFailed') + '\n\n' +
t('knowledgeBase.failedFilesList') + '\n' + failedList +
(moreCount > 0 ? `\n${t('knowledgeBase.andMoreFiles', { count: moreCount })}` : ''),
content: t('knowledgeBase.uploadAllFailed') + extraContent,
duration: 8000,
closeBtn: true
})
@@ -950,32 +989,69 @@ const handleDocFolderChange = async (event: Event) => {
const failedFiles: Array<{ name: string; reason: string }> = []
for (const file of validFiles) {
try {
// 获取文件的相对路径(webkitRelativePath)
const relativePath = (file as any).webkitRelativePath
let fileName = file.name
// 如果存在相对路径,提取子文件夹路径并拼接到文件名前
if (relativePath) {
// webkitRelativePath 格式: "文件夹名/子文件夹/文件名.ext"
// 我们需要去掉第一层文件夹名,保留子路径
const pathParts = relativePath.split('/')
if (pathParts.length > 2) {
// 有子文件夹,拼接子路径到文件名
const subPath = pathParts.slice(1, -1).join('/') // 去掉顶层文件夹和文件名本身
fileName = `${subPath}/${file.name}`
}
// 获取文件的相对路径(webkitRelativePath),用于保留子目录结构
const relativePath = (file as any).webkitRelativePath
let fileName = file.name
if (relativePath) {
const pathParts = relativePath.split('/')
if (pathParts.length > 2) {
const subPath = pathParts.slice(1, -1).join('/')
fileName = `${subPath}/${file.name}`
}
await uploadKnowledgeFile(kbId, { file, fileName })
}
const uploadId = `${file.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
let progress = 0
let status: 'uploading' | 'success' | 'error' = 'uploading'
let errorReason: string | undefined
window.dispatchEvent(new CustomEvent('knowledgeFileUploadStart', {
detail: {
kbId,
uploadId,
fileName
}
}))
try {
await uploadKnowledgeFile(
kbId,
{ file, fileName },
(progressEvent: any) => {
if (progressEvent?.total) {
progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
window.dispatchEvent(new CustomEvent('knowledgeFileUploadProgress', {
detail: {
kbId,
uploadId,
progress
}
}))
}
}
)
successCount++
status = 'success'
progress = 100
} catch (error: any) {
failCount++
let errorReason = error?.error?.message || error?.message || t('knowledgeBase.uploadFailed')
errorReason = error?.error?.message || error?.message || t('knowledgeBase.uploadFailed')
if (error?.code === 'duplicate_file' || error?.error?.code === 'duplicate_file') {
errorReason = t('knowledgeBase.fileExists')
}
failedFiles.push({ name: file.name, reason: errorReason })
failedFiles.push({ name: fileName, reason: errorReason })
status = 'error'
} finally {
window.dispatchEvent(new CustomEvent('knowledgeFileUploadComplete', {
detail: {
kbId,
uploadId,
status,
progress,
error: errorReason,
fileName
}
}))
}
}
@@ -988,24 +1064,35 @@ const handleDocFolderChange = async (event: Event) => {
if (failCount === 0) {
MessagePlugin.success(t('knowledgeBase.uploadAllSuccess', { count: successCount }))
} else if (successCount > 0) {
// 部分成功,显示详细失败信息
const failedList = failedFiles.map(f => `• ${f.name}: ${f.reason}`).join('\n')
const { duplicateCount, nonDuplicateText } = summarizeFailedFiles(failedFiles)
const extraSections: string[] = []
if (duplicateCount > 0) {
extraSections.push(t('knowledgeBase.duplicateFilesSkipped', { count: duplicateCount }))
}
if (nonDuplicateText) {
extraSections.push(t('knowledgeBase.failedFilesList') + '\n' + nonDuplicateText)
}
const extraContent = extraSections.length ? '\n\n' + extraSections.join('\n\n') : ''
MessagePlugin.warning({
content: t('knowledgeBase.uploadPartialSuccess', {
success: successCount,
fail: failCount
}) + '\n\n' + t('knowledgeBase.failedFilesList') + '\n' + failedList,
}) + extraContent,
duration: 8000,
closeBtn: true
})
} else {
// 全部失败,显示详细失败信息
const failedList = failedFiles.slice(0, 5).map(f => `• ${f.name}: ${f.reason}`).join('\n')
const moreCount = failedFiles.length > 5 ? failedFiles.length - 5 : 0
const { duplicateCount, nonDuplicateText } = summarizeFailedFiles(failedFiles)
const extraSections: string[] = []
if (duplicateCount > 0) {
extraSections.push(t('knowledgeBase.duplicateFilesSkipped', { count: duplicateCount }))
}
if (nonDuplicateText) {
extraSections.push(t('knowledgeBase.failedFilesList') + '\n' + nonDuplicateText)
}
const extraContent = extraSections.length ? '\n\n' + extraSections.join('\n\n') : ''
MessagePlugin.error({
content: t('knowledgeBase.uploadAllFailed') + '\n\n' +
t('knowledgeBase.failedFilesList') + '\n' + failedList +
(moreCount > 0 ? `\n${t('knowledgeBase.andMoreFiles', { count: moreCount })}` : ''),
content: t('knowledgeBase.uploadAllFailed') + extraContent,
duration: 8000,
closeBtn: true
})
+11
View File
@@ -65,6 +65,7 @@ export default {
unsupportedFileType: 'Unsupported file type',
failedFilesList: 'Failed files:',
andMoreFiles: '...and {count} more files',
duplicateFilesSkipped: '{count} duplicate files skipped',
uploadFile: 'Upload File',
uploadFileDesc: 'Supports PDF, Word, TXT, etc.',
importURL: 'Import from URL',
@@ -812,6 +813,16 @@ export default {
documents: 'Document Count',
faqEntries: 'FAQ Entries',
chunks: 'Chunk Count'
},
uploadProgress: {
uploadingTitle: 'Uploading folder documents to "{name}"',
detail: '{completed}/{total} files finished',
keepPageOpen: 'Please keep this page open while files upload.',
completedTitle: 'Upload finished for "{name}"',
completedDetail: 'All {total} files uploaded. Refreshing list to show parsing status...',
refreshing: 'Refreshing list to show parsing status...',
errorTip: 'Some files failed to upload. Please check the notifications.',
unknownKb: 'Knowledge Base {id}'
}
},
knowledgeEditor: {
+11
View File
@@ -65,6 +65,7 @@ export default {
unsupportedFileType: 'Неподдерживаемый тип файла',
failedFilesList: 'Неудавшиеся файлы:',
andMoreFiles: '...и ещё {count} файлов',
duplicateFilesSkipped: 'Пропущено {count} повторяющихся файлов',
uploadFile: 'Загрузить файл',
uploadFileDesc: 'Поддерживает PDF, Word, TXT и т.д.',
importURL: 'Импорт из URL',
@@ -900,6 +901,16 @@ export default {
documents: 'Количество документов',
faqEntries: 'FAQ записи',
chunks: 'Количество фрагментов'
},
uploadProgress: {
uploadingTitle: 'Загрузка документов папки в «{name}»',
detail: 'Готово {completed} из {total} файлов',
keepPageOpen: 'Пожалуйста, не закрывайте страницу, пока идет загрузка.',
completedTitle: 'Загрузка для «{name}» завершена',
completedDetail: 'Загружено {total} файлов. Обновляем список, чтобы показать статус разбора...',
refreshing: 'Обновляем список и статусы разбора...',
errorTip: 'Часть файлов загрузить не удалось. Проверьте уведомления.',
unknownKb: 'База знаний {id}'
}
},
knowledgeEditor: {
+11
View File
@@ -64,6 +64,7 @@ export default {
unsupportedFileType: "不支持的文件格式",
failedFilesList: "失败文件列表:",
andMoreFiles: "...及其他 {count} 个文件",
duplicateFilesSkipped: "已忽略 {count} 个重复文件",
uploadFile: "上传文件",
uploadFileDesc: "支持 PDF、Word、TXT 等",
importURL: "导入网页",
@@ -1136,6 +1137,16 @@ export default {
faqEntries: "问答条目",
chunks: "分块数量",
},
uploadProgress: {
uploadingTitle: "正在向「{name}」上传文件夹中的文档",
detail: "已完成 {completed}/{total} 个文件",
keepPageOpen: "请保持页面打开,上传完成后会自动刷新解析状态。",
completedTitle: "「{name}」的上传已完成",
completedDetail: "共上传 {total} 个文件,正在刷新列表查看解析状态...",
refreshing: "正在刷新列表并获取最新解析状态...",
errorTip: "部分文件上传失败,请查看右上角通知详情。",
unknownKb: "知识库 {id}",
},
},
knowledgeEditor: {
titleCreate: "新建知识库",
@@ -15,6 +15,48 @@
<span>{{ $t('knowledgeList.uninitializedBanner') }}</span>
</div>
<!-- 上传进度提示 -->
<div v-if="uploadSummaries.length" class="upload-progress-panel">
<div
v-for="summary in uploadSummaries"
:key="summary.kbId"
class="upload-progress-item"
>
<div class="upload-progress-icon">
<t-icon :name="summary.completed === summary.total ? 'check-circle-filled' : 'upload'" size="20px" />
</div>
<div class="upload-progress-content">
<div class="progress-title">
{{
summary.completed === summary.total
? $t('knowledgeList.uploadProgress.completedTitle', { name: summary.kbName })
: $t('knowledgeList.uploadProgress.uploadingTitle', { name: summary.kbName })
}}
</div>
<div class="progress-subtitle">
{{
summary.completed === summary.total
? $t('knowledgeList.uploadProgress.completedDetail', { total: summary.total })
: $t('knowledgeList.uploadProgress.detail', { completed: summary.completed, total: summary.total })
}}
</div>
<div class="progress-subtitle secondary">
{{
summary.completed === summary.total
? $t('knowledgeList.uploadProgress.refreshing')
: $t('knowledgeList.uploadProgress.keepPageOpen')
}}
</div>
<div v-if="summary.hasError" class="progress-subtitle error">
{{ $t('knowledgeList.uploadProgress.errorTip') }}
</div>
<div class="progress-bar">
<div class="progress-bar-inner" :style="{ width: summary.progress + '%' }"></div>
</div>
</div>
</div>
</div>
<!-- 卡片网格 -->
<div v-if="kbs.length > 0" class="kb-card-wrap">
<div
@@ -155,7 +197,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref, computed, watch, nextTick } from 'vue'
import { onMounted, onUnmounted, ref, computed, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { MessagePlugin, Icon as TIcon } from 'tdesign-vue-next'
import { listKnowledgeBases, deleteKnowledgeBase } from '@/api/knowledge-base'
@@ -195,6 +237,28 @@ const deletingKb = ref<KB | null>(null)
const currentMoreIndex = ref<number>(-1)
const highlightedKbId = ref<string | null>(null)
const highlightedCardRef = ref<HTMLElement | null>(null)
const uploadTasks = ref<UploadTaskState[]>([])
const uploadCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>()
let uploadRefreshTimer: ReturnType<typeof setTimeout> | null = null
const UPLOAD_CLEANUP_DELAY = 10000
interface UploadTaskState {
uploadId: string
kbId: string
fileName?: string
progress: number
status: 'uploading' | 'success' | 'error'
error?: string
}
interface UploadSummary {
kbId: string
kbName: string
total: number
completed: number
progress: number
hasError: boolean
}
const fetchList = () => {
loading.value = true
@@ -222,6 +286,25 @@ onMounted(() => {
router.replace({ query: {} })
}
})
window.addEventListener('knowledgeFileUploadStart', handleUploadStartEvent as EventListener)
window.addEventListener('knowledgeFileUploadProgress', handleUploadProgressEvent as EventListener)
window.addEventListener('knowledgeFileUploadComplete', handleUploadCompleteEvent as EventListener)
window.addEventListener('knowledgeFileUploaded', handleUploadFinishedEvent as EventListener)
})
onUnmounted(() => {
window.removeEventListener('knowledgeFileUploadStart', handleUploadStartEvent as EventListener)
window.removeEventListener('knowledgeFileUploadProgress', handleUploadProgressEvent as EventListener)
window.removeEventListener('knowledgeFileUploadComplete', handleUploadCompleteEvent as EventListener)
window.removeEventListener('knowledgeFileUploaded', handleUploadFinishedEvent as EventListener)
uploadCleanupTimers.forEach(timer => clearTimeout(timer))
uploadCleanupTimers.clear()
if (uploadRefreshTimer) {
clearTimeout(uploadRefreshTimer)
uploadRefreshTimer = null
}
})
// 监听路由变化,处理从其他页面跳转过来的高亮需求
@@ -285,6 +368,101 @@ const hasUninitializedKbs = computed(() => {
return kbs.value.some(kb => !isInitialized(kb))
})
const getKbDisplayName = (kbId: string) => {
const target = kbs.value.find(kb => kb.id === kbId)
if (target?.name) return target.name
return t('knowledgeList.uploadProgress.unknownKb', { id: kbId }) as string
}
const uploadSummaries = computed<UploadSummary[]>(() => {
if (!uploadTasks.value.length) return []
const grouped: Record<string, UploadTaskState[]> = {}
uploadTasks.value.forEach(task => {
const kbKey = String(task.kbId)
if (!grouped[kbKey]) grouped[kbKey] = []
grouped[kbKey].push(task)
})
return Object.entries(grouped).map(([kbId, tasks]) => {
const total = tasks.length
const completed = tasks.filter(task => task.status !== 'uploading').length
const progressSum = tasks.reduce((sum, task) => sum + (task.progress ?? 0), 0)
const avgProgress = total === 0 ? 0 : Math.min(100, Math.max(0, Math.round(progressSum / total)))
const hasError = tasks.some(task => task.status === 'error')
return {
kbId,
kbName: getKbDisplayName(kbId),
total,
completed,
progress: avgProgress,
hasError
}
}).sort((a, b) => a.kbName.localeCompare(b.kbName))
})
const clampProgress = (value: number) => Math.min(100, Math.max(0, Math.round(value)))
const addUploadTask = (task: UploadTaskState) => {
uploadTasks.value = [
...uploadTasks.value.filter(item => item.uploadId !== task.uploadId),
task,
]
}
const patchUploadTask = (uploadId: string, patch: Partial<UploadTaskState>) => {
const index = uploadTasks.value.findIndex(task => task.uploadId === uploadId)
if (index === -1) return
const nextTasks = [...uploadTasks.value]
nextTasks[index] = { ...nextTasks[index], ...patch }
uploadTasks.value = nextTasks
}
const removeUploadTask = (uploadId: string) => {
uploadTasks.value = uploadTasks.value.filter(task => task.uploadId !== uploadId)
const timer = uploadCleanupTimers.get(uploadId)
if (timer) {
clearTimeout(timer)
uploadCleanupTimers.delete(uploadId)
}
}
const scheduleUploadTaskCleanup = (uploadId: string) => {
const existing = uploadCleanupTimers.get(uploadId)
if (existing) {
clearTimeout(existing)
}
const timer = setTimeout(() => {
removeUploadTask(uploadId)
}, UPLOAD_CLEANUP_DELAY)
uploadCleanupTimers.set(uploadId, timer)
}
type UploadEventDetail = {
uploadId: string
kbId?: string | number
fileName?: string
progress?: number
status?: UploadTaskState['status']
error?: string
}
const ensureUploadTaskEntry = (detail?: UploadEventDetail) => {
if (!detail?.uploadId) return null
const existing = uploadTasks.value.find(task => task.uploadId === detail.uploadId)
if (existing) return existing
if (!detail.kbId) return null
const initialProgress = typeof detail.progress === 'number' ? clampProgress(detail.progress) : 0
const newTask: UploadTaskState = {
uploadId: detail.uploadId,
kbId: String(detail.kbId),
fileName: detail.fileName,
progress: initialProgress,
status: detail.status || 'uploading',
error: detail.error
}
addUploadTask(newTask)
return newTask
}
const handleCardClick = (kb: KB) => {
if (isInitialized(kb)) {
goDetail(kb.id)
@@ -332,6 +510,54 @@ const triggerHighlightFlash = (kbId: string) => {
}, 3000)
})
}
const handleUploadStartEvent = (event: Event) => {
const detail = (event as CustomEvent<UploadEventDetail>).detail
if (!detail?.uploadId || !detail?.kbId) return
addUploadTask({
uploadId: detail.uploadId,
kbId: String(detail.kbId),
fileName: detail.fileName,
progress: typeof detail.progress === 'number' ? clampProgress(detail.progress) : 0,
status: 'uploading'
})
}
const handleUploadProgressEvent = (event: Event) => {
const detail = (event as CustomEvent<UploadEventDetail>).detail
if (!detail?.uploadId || typeof detail.progress !== 'number') return
if (!ensureUploadTaskEntry(detail)) return
patchUploadTask(detail.uploadId, {
progress: clampProgress(detail.progress)
})
}
const handleUploadCompleteEvent = (event: Event) => {
const detail = (event as CustomEvent<UploadEventDetail>).detail
if (!detail?.uploadId) return
const progress = typeof detail.progress === 'number'
? clampProgress(detail.progress)
: 100
if (!ensureUploadTaskEntry({ ...detail, progress })) return
patchUploadTask(detail.uploadId, {
status: detail.status || 'success',
progress,
error: detail.error
})
scheduleUploadTaskCleanup(detail.uploadId)
}
const handleUploadFinishedEvent = (event: Event) => {
const detail = (event as CustomEvent<{ kbId?: string | number }>).detail
if (!detail?.kbId) return
if (uploadRefreshTimer) {
clearTimeout(uploadRefreshTimer)
}
uploadRefreshTimer = setTimeout(() => {
fetchList()
uploadRefreshTimer = null
}, 800)
}
</script>
<style scoped lang="less">
@@ -401,6 +627,75 @@ const triggerHighlightFlash = (kbId: string) => {
}
}
.upload-progress-panel {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.upload-progress-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid #e7ebf0;
border-radius: 8px;
background: #f7fbff;
}
.upload-progress-icon {
color: #07c05f;
display: flex;
align-items: center;
justify-content: center;
}
.upload-progress-content {
flex: 1;
}
.progress-title {
color: #0f172a;
font-family: "PingFang SC";
font-size: 14px;
font-weight: 600;
line-height: 22px;
margin-bottom: 2px;
}
.progress-subtitle {
color: #475467;
font-family: "PingFang SC";
font-size: 12px;
line-height: 18px;
}
.progress-subtitle.secondary {
color: #94a3b8;
margin-top: 2px;
}
.progress-subtitle.error {
color: #d92d20;
margin-top: 4px;
}
.progress-bar {
width: 100%;
height: 6px;
border-radius: 999px;
background: #e7eef5;
margin-top: 10px;
overflow: hidden;
}
.progress-bar-inner {
height: 100%;
background: linear-gradient(90deg, #05a04f 0%, #07c05f 100%);
transition: width 0.2s ease;
}
.kb-card-wrap {
display: grid;
gap: 20px;
Submodule graphiti deleted from d2654003ff
+4 -2
View File
@@ -302,7 +302,8 @@ For every retrieval attempt (Phase 1 or Phase 3), follow this exact chain:
### Final Output Standards
* **Definitive:** Based strictly on the "Deep Read" content.
* **Sourced:** Cite sources immediately after claims: <kb doc="..." chunk_id="..." /> or <web url="..." title="..." />.
* **Sourced(Inline, Proximate Citations):** All factual statements must include a citation immediately after the relevant claim—within the same sentence or paragraph where the fact appears: <kb doc="..." chunk_id="..." /> or <web url="..." title="..." />.
Citations may not be placed at the end of the answer. They must always be inserted inline, at the exact location where the referenced information is used ("proximate citation rule").
* **Structured:** Clear hierarchy and logic.
### System Status
@@ -383,7 +384,8 @@ For every information seeking step, strictly follow this 3-step atomic unit:
### Final Output Standards
1. **Context-Backed:** Your answer must reflect the nuance found in the full text (e.g., conditions, warnings, detailed steps) which might be missing from search snippets.
2. **Sourced:** Cite sources immediately after claims: <kb doc="..." chunk_id="..." /> .
2 **Sourced(Inline, Proximate Citations):** All factual statements must include a citation immediately after the relevant claim—within the same sentence or paragraph where the fact appears: <kb doc="..." chunk_id="..." />.
Citations may not be placed at the end of the answer. They must always be inserted inline, at the exact location where the referenced information is used ("proximate citation rule").
3. **Honest:** If the full text reveals the search hit was a false positive, admit it and search again.
### System Status
+6 -7
View File
@@ -255,6 +255,7 @@ type chunkWithTitle struct {
KnowledgeTitle string `json:"knowledge_title" gorm:"column:knowledge_title"`
MatchScore float64 `json:"match_score" gorm:"column:match_score"` // Score based on match count and position
MatchedPatterns int `json:"matched_patterns"` // Number of unique patterns matched
TotalChunkCount int `json:"total_chunk_count" gorm:"column:total_chunk_count"`
}
// searchChunks performs the database search with pattern matching
@@ -265,7 +266,7 @@ func (t *GrepChunksTool) searchChunks(
) ([]chunkWithTitle, int64, error) {
// Build base query
query := t.db.Debug().WithContext(ctx).Table("chunks").
Select("chunks.id, chunks.content, chunks.chunk_index, chunks.knowledge_id, chunks.knowledge_base_id, chunks.chunk_type, chunks.created_at, knowledges.title as knowledge_title").
Select("chunks.id, chunks.content, chunks.chunk_index, chunks.knowledge_id, chunks.knowledge_base_id, chunks.chunk_type, chunks.created_at, knowledges.title as knowledge_title, COUNT(*) OVER (PARTITION BY chunks.knowledge_id) AS total_chunk_count").
Joins("LEFT JOIN knowledges ON chunks.knowledge_id = knowledges.id").
Where("chunks.tenant_id = ?", t.tenantID).
Where("chunks.is_enabled = ?", true).
@@ -333,11 +334,6 @@ func (t *GrepChunksTool) formatOutput(
if len(results) == 0 {
output.WriteString("No matches found.\n")
output.WriteString("\n=== ⚠️ CRITICAL - Next Steps ===\n")
output.WriteString("- ❌ DO NOT use training data or general knowledge to answer\n")
output.WriteString("- ✅ Try knowledge_search for semantic search\n")
output.WriteString("- ✅ If KB search fails and web_search is enabled: You MUST use web_search\n")
output.WriteString("- NEVER fabricate or infer answers - ONLY use retrieved content\n")
return output.String()
}
@@ -348,11 +344,12 @@ func (t *GrepChunksTool) formatOutput(
patternSummaries = append(patternSummaries, fmt.Sprintf("%s=%d", pattern, count))
}
output.WriteString(fmt.Sprintf("%d) knowledge_id=%s | title=%s | chunk_hits=%d | pattern_hits=[%s]\n",
output.WriteString(fmt.Sprintf("%d) knowledge_id=%s | title=%s | chunk_hits=%d | chunk_total=%d | pattern_hits=[%s]\n",
idx+1,
result.KnowledgeID,
result.KnowledgeTitle,
result.ChunkHitCount,
result.TotalChunkCount,
strings.Join(patternSummaries, ", "),
))
}
@@ -364,6 +361,7 @@ type knowledgeAggregation struct {
KnowledgeBaseID string `json:"knowledge_base_id"`
KnowledgeTitle string `json:"knowledge_title"`
ChunkHitCount int `json:"chunk_hit_count"`
TotalChunkCount int `json:"total_chunk_count"`
PatternCounts map[string]int `json:"pattern_counts"`
TotalPatternHits int `json:"total_pattern_hits"`
DistinctPatterns int `json:"distinct_patterns"`
@@ -398,6 +396,7 @@ func (t *GrepChunksTool) aggregateByKnowledge(results []chunkWithTitle, patterns
KnowledgeID: knowledgeID,
KnowledgeBaseID: chunk.KnowledgeBaseID,
KnowledgeTitle: title,
TotalChunkCount: chunk.TotalChunkCount,
PatternCounts: make(map[string]int, len(patternKeys)),
}
for _, pKey := range patternKeys {