feat(AISVC-T6.9): 前后端接口对接修正及Dashboard/RAG Lab功能完善
## 后端修改 - 新增 Dashboard 统计 API (/admin/dashboard/stats) - 新增知识库列表 API (/admin/kb/knowledge-bases),返回文档数量 - 会话列表 API 新增 tenantId 字段 - KBService 新增 list_knowledge_bases 方法 ## 前端修改 - Dashboard 页面对接真实后端 API - RAG Lab 知识库选择器显示文档数量 - Monitoring 页面修复数据映射 - 新增 dashboard.ts API 文件 - kb.ts 新增 listKnowledgeBases 函数
This commit is contained in:
parent
8731beaeb5
commit
5148c6ef42
|
|
@ -0,0 +1,11 @@
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Dashboard 统计数据
|
||||||
|
*/
|
||||||
|
export function getDashboardStats() {
|
||||||
|
return request({
|
||||||
|
url: '/admin/dashboard/stats',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
import request from '@/utils/request'
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询知识库列表
|
||||||
|
*/
|
||||||
|
export function listKnowledgeBases() {
|
||||||
|
return request({
|
||||||
|
url: '/admin/kb/knowledge-bases',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询文档列表 [AC-ASA-08]
|
* 查询文档列表 [AC-ASA-08]
|
||||||
*/
|
*/
|
||||||
|
|
@ -31,3 +41,13 @@ export function getIndexJob(jobId: string) {
|
||||||
method: 'get'
|
method: 'get'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文档 [AC-ASA-08]
|
||||||
|
*/
|
||||||
|
export function deleteDocument(docId: string) {
|
||||||
|
return request({
|
||||||
|
url: `/admin/kb/documents/${docId}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,66 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="dashboard-container">
|
<div class="dashboard-container">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20" v-loading="loading">
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-card shadow="hover">
|
<el-card shadow="hover">
|
||||||
<template #header>知识库总数</template>
|
<template #header>知识库总数</template>
|
||||||
<div class="card-content">12</div>
|
<div class="card-content">{{ stats.knowledgeBases }}</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header>文档总数</template>
|
||||||
|
<div class="card-content">{{ stats.totalDocuments }}</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-card shadow="hover">
|
<el-card shadow="hover">
|
||||||
<template #header>总消息数</template>
|
<template #header>总消息数</template>
|
||||||
<div class="card-content">1,284</div>
|
<div class="card-content">{{ stats.totalMessages.toLocaleString() }}</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-card shadow="hover">
|
<el-card shadow="hover">
|
||||||
<template #header>平均响应时间</template>
|
<template #header>会话总数</template>
|
||||||
<div class="card-content">1.2s</div>
|
<div class="card-content">{{ stats.totalSessions }}</div>
|
||||||
</el-card>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="6">
|
|
||||||
<el-card shadow="hover">
|
|
||||||
<template #header>活跃租户</template>
|
|
||||||
<div class="card-content">5</div>
|
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { getDashboardStats } from '@/api/dashboard'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const stats = reactive({
|
||||||
|
knowledgeBases: 0,
|
||||||
|
totalDocuments: 0,
|
||||||
|
totalMessages: 0,
|
||||||
|
totalSessions: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await getDashboardStats()
|
||||||
|
stats.knowledgeBases = res.knowledgeBases || 0
|
||||||
|
stats.totalDocuments = res.totalDocuments || 0
|
||||||
|
stats.totalMessages = res.totalMessages || 0
|
||||||
|
stats.totalSessions = res.totalSessions || 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch dashboard stats:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchStats()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dashboard-container {
|
.dashboard-container {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
<el-table-column label="操作" width="180">
|
<el-table-column label="操作" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="primary" @click="handleViewJob(scope.row)">查看详情</el-button>
|
<el-button link type="primary" @click="handleViewJob(scope.row)">查看详情</el-button>
|
||||||
<el-button link type="danger">删除</el-button>
|
<el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
@ -54,10 +54,11 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { uploadDocument, listDocuments, getIndexJob } from '@/api/kb'
|
import { uploadDocument, listDocuments, getIndexJob, deleteDocument } from '@/api/kb'
|
||||||
|
|
||||||
interface DocumentItem {
|
interface DocumentItem {
|
||||||
|
docId: string
|
||||||
name: string
|
name: string
|
||||||
status: string
|
status: string
|
||||||
jobId: string
|
jobId: string
|
||||||
|
|
@ -95,9 +96,10 @@ const fetchDocuments = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await listDocuments({})
|
const res = await listDocuments({})
|
||||||
tableData.value = res.data.map((doc: any) => ({
|
tableData.value = res.data.map((doc: any) => ({
|
||||||
|
docId: doc.docId,
|
||||||
name: doc.fileName,
|
name: doc.fileName,
|
||||||
status: doc.status,
|
status: doc.status,
|
||||||
jobId: doc.docId,
|
jobId: doc.jobId,
|
||||||
createTime: new Date(doc.createdAt).toLocaleString('zh-CN')
|
createTime: new Date(doc.createdAt).toLocaleString('zh-CN')
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -124,6 +126,24 @@ const handleViewJob = async (row: DocumentItem) => {
|
||||||
jobDialogVisible.value = true
|
jobDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (row: DocumentItem) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定要删除文档 "${row.name}" 吗?`, '确认删除', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
|
||||||
|
await deleteDocument(row.docId)
|
||||||
|
ElMessage.success('文档删除成功')
|
||||||
|
fetchDocuments()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const refreshJobStatus = async () => {
|
const refreshJobStatus = async () => {
|
||||||
if (currentJob.value?.jobId) {
|
if (currentJob.value?.jobId) {
|
||||||
currentJob.value = await fetchJobStatus(currentJob.value.jobId)
|
currentJob.value = await fetchJobStatus(currentJob.value.jobId)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 使用 BaseTable 展示会话列表 [AC-ASA-09] -->
|
|
||||||
<base-table
|
<base-table
|
||||||
:data="tableData"
|
:data="tableData"
|
||||||
:total="total"
|
:total="total"
|
||||||
|
|
@ -31,8 +30,8 @@
|
||||||
@pagination="getList"
|
@pagination="getList"
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
>
|
>
|
||||||
<el-table-column prop="sessionId" label="会话 ID" width="180" show-overflow-tooltip />
|
<el-table-column prop="sessionId" label="会话 ID" width="280" show-overflow-tooltip />
|
||||||
<el-table-column prop="tenantId" label="租户 ID" width="120" />
|
<el-table-column prop="tenantId" label="租户 ID" width="280" show-overflow-tooltip />
|
||||||
<el-table-column prop="messageCount" label="消息数" width="100" align="center" />
|
<el-table-column prop="messageCount" label="消息数" width="100" align="center" />
|
||||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
|
@ -41,6 +40,7 @@
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column prop="channelType" label="渠道" width="100" />
|
||||||
<el-table-column prop="startTime" label="开始时间" width="180" />
|
<el-table-column prop="startTime" label="开始时间" width="180" />
|
||||||
<el-table-column label="操作" fixed="right" width="120" align="center">
|
<el-table-column label="操作" fixed="right" width="120" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
|
@ -50,7 +50,6 @@
|
||||||
</base-table>
|
</base-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 全链路追踪详情抽屉 [AC-ASA-07] -->
|
|
||||||
<el-drawer
|
<el-drawer
|
||||||
v-model="drawerVisible"
|
v-model="drawerVisible"
|
||||||
title="会话全链路追踪详情"
|
title="会话全链路追踪详情"
|
||||||
|
|
@ -59,7 +58,15 @@
|
||||||
>
|
>
|
||||||
<div v-loading="detailLoading" class="detail-container">
|
<div v-loading="detailLoading" class="detail-container">
|
||||||
<el-empty v-if="!sessionDetail && !detailLoading" description="暂无追踪详情" />
|
<el-empty v-if="!sessionDetail && !detailLoading" description="暂无追踪详情" />
|
||||||
<el-timeline v-else>
|
<div v-else>
|
||||||
|
<el-descriptions :column="1" border class="session-info">
|
||||||
|
<el-descriptions-item label="会话ID">{{ sessionDetail?.sessionId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="消息数">{{ sessionDetail?.messages?.length || 0 }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<el-divider content-position="left">消息记录</el-divider>
|
||||||
|
|
||||||
|
<el-timeline>
|
||||||
<el-timeline-item
|
<el-timeline-item
|
||||||
v-for="(msg, index) in sessionDetail?.messages"
|
v-for="(msg, index) in sessionDetail?.messages"
|
||||||
:key="index"
|
:key="index"
|
||||||
|
|
@ -72,12 +79,17 @@
|
||||||
<span class="role-tag" :class="msg.role">{{ msg.role === 'user' ? 'USER' : 'ASSISTANT' }}</span>
|
<span class="role-tag" :class="msg.role">{{ msg.role === 'user' ? 'USER' : 'ASSISTANT' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="msg-content">{{ msg.content }}</div>
|
<div class="msg-content">{{ msg.content }}</div>
|
||||||
|
</el-card>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
|
||||||
<!-- 展示追踪信息:检索命中、工具调用等 [AC-ASA-07] -->
|
<el-divider content-position="left" v-if="sessionDetail?.trace && (sessionDetail.trace.retrieval?.length || sessionDetail.trace.tools?.length)">
|
||||||
<div v-if="msg.trace" class="trace-info">
|
追踪信息
|
||||||
<el-collapse class="trace-collapse">
|
</el-divider>
|
||||||
<el-collapse-item v-if="msg.trace.retrieval" title="检索追踪 (Retrieval)" name="retrieval">
|
|
||||||
<div v-for="(hit, hIdx) in msg.trace.retrieval" :key="hIdx" class="hit-item">
|
<el-collapse v-if="sessionDetail?.trace">
|
||||||
|
<el-collapse-item v-if="sessionDetail.trace.retrieval?.length" title="检索追踪 (Retrieval)" name="retrieval">
|
||||||
|
<div v-for="(hit, hIdx) in sessionDetail.trace.retrieval" :key="hIdx" class="hit-item">
|
||||||
<div class="hit-meta">
|
<div class="hit-meta">
|
||||||
<el-tag size="small" type="success">Score: {{ hit.score }}</el-tag>
|
<el-tag size="small" type="success">Score: {{ hit.score }}</el-tag>
|
||||||
<span class="hit-source" v-if="hit.source">来源: {{ hit.source }}</span>
|
<span class="hit-source" v-if="hit.source">来源: {{ hit.source }}</span>
|
||||||
|
|
@ -85,14 +97,11 @@
|
||||||
<div class="hit-text">{{ hit.content }}</div>
|
<div class="hit-text">{{ hit.content }}</div>
|
||||||
</div>
|
</div>
|
||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
<el-collapse-item v-if="msg.trace.tool_calls" title="工具调用 (Tool Calls)" name="tools">
|
<el-collapse-item v-if="sessionDetail.trace.tools?.length" title="工具调用 (Tool Calls)" name="tools">
|
||||||
<pre class="code-block"><code>{{ JSON.stringify(msg.trace.tool_calls, null, 2) }}</code></pre>
|
<pre class="code-block"><code>{{ JSON.stringify(sessionDetail.trace.tools, null, 2) }}</code></pre>
|
||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
</el-collapse>
|
</el-collapse>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
|
||||||
</el-timeline-item>
|
|
||||||
</el-timeline>
|
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -128,6 +137,8 @@ const getList = async () => {
|
||||||
const res: any = await listSessions(queryParams)
|
const res: any = await listSessions(queryParams)
|
||||||
tableData.value = res.data || []
|
tableData.value = res.data || []
|
||||||
total.value = res.pagination?.total || 0
|
total.value = res.pagination?.total || 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch sessions:', error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -143,12 +154,13 @@ const resetQuery = () => {
|
||||||
handleQuery()
|
handleQuery()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取并展示全链路追踪详情 [AC-ASA-07] */
|
|
||||||
const handleTrace = async (row: any) => {
|
const handleTrace = async (row: any) => {
|
||||||
drawerVisible.value = true
|
drawerVisible.value = true
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
try {
|
try {
|
||||||
sessionDetail.value = await getSessionDetail(row.sessionId)
|
sessionDetail.value = await getSessionDetail(row.sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch session detail:', error)
|
||||||
} finally {
|
} finally {
|
||||||
detailLoading.value = false
|
detailLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -164,15 +176,13 @@ onMounted(() => {
|
||||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.title { font-size: 16px; font-weight: bold; }
|
.title { font-size: 16px; font-weight: bold; }
|
||||||
.detail-container { padding: 10px 20px; }
|
.detail-container { padding: 10px 20px; }
|
||||||
|
.session-info { margin-bottom: 20px; }
|
||||||
.msg-card { border-radius: 8px; margin-bottom: 10px; }
|
.msg-card { border-radius: 8px; margin-bottom: 10px; }
|
||||||
.msg-header { margin-bottom: 8px; }
|
.msg-header { margin-bottom: 8px; }
|
||||||
.role-tag { font-size: 11px; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
|
.role-tag { font-size: 11px; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
|
||||||
.role-tag.user { background-color: #ecf5ff; color: #409eff; }
|
.role-tag.user { background-color: #ecf5ff; color: #409eff; }
|
||||||
.role-tag.assistant { background-color: #f0f9eb; color: #67c23a; }
|
.role-tag.assistant { background-color: #f0f9eb; color: #67c23a; }
|
||||||
.msg-content { font-size: 14px; line-height: 1.6; color: #333; white-space: pre-wrap; }
|
.msg-content { font-size: 14px; line-height: 1.6; color: #333; white-space: pre-wrap; }
|
||||||
.trace-info { margin-top: 15px; border-top: 1px solid #f0f0f0; padding-top: 10px; }
|
|
||||||
.trace-collapse { border: none; }
|
|
||||||
:deep(.el-collapse-item__header) { height: 36px; font-size: 13px; color: #909399; }
|
|
||||||
.hit-item { padding: 10px; background-color: #f8f9fa; border-radius: 4px; margin-bottom: 8px; }
|
.hit-item { padding: 10px; background-color: #f8f9fa; border-radius: 4px; margin-bottom: 8px; }
|
||||||
.hit-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
.hit-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||||
.hit-source { font-size: 11px; color: #999; }
|
.hit-source { font-size: 11px; color: #999; }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="rag-lab-container">
|
<div class="rag-lab-container">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<!-- 左侧:调试输入 [AC-ASA-05] -->
|
|
||||||
<el-col :span="10">
|
<el-col :span="10">
|
||||||
<el-card header="调试输入">
|
<el-card header="调试输入">
|
||||||
<el-form label-position="top">
|
<el-form label-position="top">
|
||||||
|
|
@ -19,8 +18,14 @@
|
||||||
multiple
|
multiple
|
||||||
placeholder="请选择知识库"
|
placeholder="请选择知识库"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
|
:loading="kbLoading"
|
||||||
>
|
>
|
||||||
<el-option label="默认知识库" value="default" />
|
<el-option
|
||||||
|
v-for="kb in knowledgeBases"
|
||||||
|
:key="kb.id"
|
||||||
|
:label="`${kb.name} (${kb.documentCount}个文档)`"
|
||||||
|
:value="kb.id"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="参数配置">
|
<el-form-item label="参数配置">
|
||||||
|
|
@ -46,7 +51,6 @@
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
<!-- 右侧:实验结果 [AC-ASA-05] -->
|
|
||||||
<el-col :span="14">
|
<el-col :span="14">
|
||||||
<el-tabs v-model="activeTab" type="border-card">
|
<el-tabs v-model="activeTab" type="border-card">
|
||||||
<el-tab-pane label="召回片段" name="retrieval">
|
<el-tab-pane label="召回片段" name="retrieval">
|
||||||
|
|
@ -76,6 +80,14 @@
|
||||||
<pre><code>{{ results.finalPrompt }}</code></pre>
|
<pre><code>{{ results.finalPrompt }}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="诊断信息" name="diagnostics">
|
||||||
|
<div v-if="!results.diagnostics" class="placeholder-text">
|
||||||
|
等待实验运行...
|
||||||
|
</div>
|
||||||
|
<div v-else class="diagnostics-view">
|
||||||
|
<pre><code>{{ JSON.stringify(results.diagnostics, null, 2) }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
@ -83,16 +95,25 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { runRagExperiment } from '@/api/rag'
|
import { runRagExperiment } from '@/api/rag'
|
||||||
|
import { listKnowledgeBases } from '@/api/kb'
|
||||||
|
|
||||||
|
interface KnowledgeBase {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
documentCount: number
|
||||||
|
}
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const kbLoading = ref(false)
|
||||||
const activeTab = ref('retrieval')
|
const activeTab = ref('retrieval')
|
||||||
|
const knowledgeBases = ref<KnowledgeBase[]>([])
|
||||||
|
|
||||||
const queryParams = reactive({
|
const queryParams = reactive({
|
||||||
query: '',
|
query: '',
|
||||||
kbIds: [],
|
kbIds: [] as string[],
|
||||||
params: {
|
params: {
|
||||||
topK: 3,
|
topK: 3,
|
||||||
threshold: 0.5
|
threshold: 0.5
|
||||||
|
|
@ -100,11 +121,23 @@ const queryParams = reactive({
|
||||||
})
|
})
|
||||||
|
|
||||||
const results = reactive({
|
const results = reactive({
|
||||||
retrievalResults: [],
|
retrievalResults: [] as any[],
|
||||||
finalPrompt: ''
|
finalPrompt: '',
|
||||||
|
diagnostics: null as any
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 运行实验 [AC-ASA-05] */
|
const fetchKnowledgeBases = async () => {
|
||||||
|
kbLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await listKnowledgeBases()
|
||||||
|
knowledgeBases.value = res.data || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch knowledge bases:', error)
|
||||||
|
} finally {
|
||||||
|
kbLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleRun = async () => {
|
const handleRun = async () => {
|
||||||
if (!queryParams.query.trim()) {
|
if (!queryParams.query.trim()) {
|
||||||
ElMessage.warning('请输入查询 Query')
|
ElMessage.warning('请输入查询 Query')
|
||||||
|
|
@ -116,14 +149,20 @@ const handleRun = async () => {
|
||||||
const res: any = await runRagExperiment(queryParams)
|
const res: any = await runRagExperiment(queryParams)
|
||||||
results.retrievalResults = res.retrievalResults || []
|
results.retrievalResults = res.retrievalResults || []
|
||||||
results.finalPrompt = res.finalPrompt || ''
|
results.finalPrompt = res.finalPrompt || ''
|
||||||
|
results.diagnostics = res.diagnostics || null
|
||||||
activeTab.value = 'retrieval'
|
activeTab.value = 'retrieval'
|
||||||
ElMessage.success('实验运行成功')
|
ElMessage.success('实验运行成功')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
ElMessage.error('实验运行失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchKnowledgeBases()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -148,14 +187,14 @@ const handleRun = async () => {
|
||||||
}
|
}
|
||||||
.source { font-size: 12px; color: #909399; }
|
.source { font-size: 12px; color: #909399; }
|
||||||
.result-content { font-size: 14px; line-height: 1.6; color: #303133; }
|
.result-content { font-size: 14px; line-height: 1.6; color: #303133; }
|
||||||
.prompt-view {
|
.prompt-view, .diagnostics-view {
|
||||||
background-color: #f5f7fa;
|
background-color: #f5f7fa;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.prompt-view pre {
|
.prompt-view pre, .diagnostics-view pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ Admin API routes for AI Service management.
|
||||||
[AC-ASA-01, AC-ASA-02, AC-ASA-05, AC-ASA-07, AC-ASA-08] Admin management endpoints.
|
[AC-ASA-01, AC-ASA-02, AC-ASA-05, AC-ASA-07, AC-ASA-08] Admin management endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from app.api.admin.dashboard import router as dashboard_router
|
||||||
from app.api.admin.kb import router as kb_router
|
from app.api.admin.kb import router as kb_router
|
||||||
from app.api.admin.rag import router as rag_router
|
from app.api.admin.rag import router as rag_router
|
||||||
from app.api.admin.sessions import router as sessions_router
|
from app.api.admin.sessions import router as sessions_router
|
||||||
|
|
||||||
__all__ = ["kb_router", "rag_router", "sessions_router"]
|
__all__ = ["dashboard_router", "kb_router", "rag_router", "sessions_router"]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
"""
|
||||||
|
Dashboard statistics endpoints.
|
||||||
|
Provides overview statistics for the admin dashboard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.core.exceptions import MissingTenantIdException
|
||||||
|
from app.core.tenant import get_tenant_id
|
||||||
|
from app.models import ErrorResponse
|
||||||
|
from app.models.entities import ChatMessage, ChatSession, Document, KnowledgeBase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/dashboard", tags=["Dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_tenant_id() -> str:
|
||||||
|
"""Dependency to get current tenant ID or raise exception."""
|
||||||
|
tenant_id = get_tenant_id()
|
||||||
|
if not tenant_id:
|
||||||
|
raise MissingTenantIdException()
|
||||||
|
return tenant_id
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/stats",
|
||||||
|
operation_id="getDashboardStats",
|
||||||
|
summary="Get dashboard statistics",
|
||||||
|
description="Get overview statistics for the admin dashboard.",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Dashboard statistics"},
|
||||||
|
401: {"description": "Unauthorized", "model": ErrorResponse},
|
||||||
|
403: {"description": "Forbidden", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_dashboard_stats(
|
||||||
|
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Get dashboard statistics including knowledge bases, messages, and activity.
|
||||||
|
"""
|
||||||
|
logger.info(f"Getting dashboard stats: tenant={tenant_id}")
|
||||||
|
|
||||||
|
kb_count_stmt = select(func.count()).select_from(KnowledgeBase).where(
|
||||||
|
KnowledgeBase.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
kb_result = await session.execute(kb_count_stmt)
|
||||||
|
kb_count = kb_result.scalar() or 0
|
||||||
|
|
||||||
|
msg_count_stmt = select(func.count()).select_from(ChatMessage).where(
|
||||||
|
ChatMessage.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
msg_result = await session.execute(msg_count_stmt)
|
||||||
|
msg_count = msg_result.scalar() or 0
|
||||||
|
|
||||||
|
doc_count_stmt = select(func.count()).select_from(Document).where(
|
||||||
|
Document.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
doc_result = await session.execute(doc_count_stmt)
|
||||||
|
doc_count = doc_result.scalar() or 0
|
||||||
|
|
||||||
|
session_count_stmt = select(func.count()).select_from(ChatSession).where(
|
||||||
|
ChatSession.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
session_result = await session.execute(session_count_stmt)
|
||||||
|
session_count = session_result.scalar() or 0
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"knowledgeBases": kb_count,
|
||||||
|
"totalMessages": msg_count,
|
||||||
|
"totalDocuments": doc_count,
|
||||||
|
"totalSessions": session_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -8,15 +8,16 @@ import os
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query, UploadFile, File, Form
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.core.exceptions import MissingTenantIdException
|
from app.core.exceptions import MissingTenantIdException
|
||||||
from app.core.tenant import get_tenant_id
|
from app.core.tenant import get_tenant_id
|
||||||
from app.models import ErrorResponse
|
from app.models import ErrorResponse
|
||||||
from app.models.entities import DocumentStatus, IndexJobStatus
|
from app.models.entities import DocumentStatus, IndexJob, IndexJobStatus
|
||||||
from app.services.kb import KBService
|
from app.services.kb import KBService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -32,6 +33,57 @@ def get_current_tenant_id() -> str:
|
||||||
return tenant_id
|
return tenant_id
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/knowledge-bases",
|
||||||
|
operation_id="listKnowledgeBases",
|
||||||
|
summary="Query knowledge base list",
|
||||||
|
description="Get list of knowledge bases for the current tenant.",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Knowledge base list"},
|
||||||
|
401: {"description": "Unauthorized", "model": ErrorResponse},
|
||||||
|
403: {"description": "Forbidden", "model": ErrorResponse},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def list_knowledge_bases(
|
||||||
|
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
List all knowledge bases for the current tenant.
|
||||||
|
"""
|
||||||
|
logger.info(f"Listing knowledge bases: tenant={tenant_id}")
|
||||||
|
|
||||||
|
kb_service = KBService(session)
|
||||||
|
knowledge_bases = await kb_service.list_knowledge_bases(tenant_id)
|
||||||
|
|
||||||
|
kb_ids = [str(kb.id) for kb in knowledge_bases]
|
||||||
|
|
||||||
|
doc_counts = {}
|
||||||
|
if kb_ids:
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.models.entities import Document
|
||||||
|
count_stmt = (
|
||||||
|
select(Document.kb_id, func.count(Document.id).label("count"))
|
||||||
|
.where(Document.tenant_id == tenant_id, Document.kb_id.in_(kb_ids))
|
||||||
|
.group_by(Document.kb_id)
|
||||||
|
)
|
||||||
|
count_result = await session.execute(count_stmt)
|
||||||
|
for row in count_result:
|
||||||
|
doc_counts[row.kb_id] = row.count
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for kb in knowledge_bases:
|
||||||
|
kb_id_str = str(kb.id)
|
||||||
|
data.append({
|
||||||
|
"id": kb_id_str,
|
||||||
|
"name": kb.name,
|
||||||
|
"documentCount": doc_counts.get(kb_id_str, 0),
|
||||||
|
"createdAt": kb.created_at.isoformat() + "Z",
|
||||||
|
})
|
||||||
|
|
||||||
|
return JSONResponse(content={"data": data})
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/documents",
|
"/documents",
|
||||||
operation_id="listDocuments",
|
operation_id="listDocuments",
|
||||||
|
|
@ -70,17 +122,24 @@ async def list_documents(
|
||||||
|
|
||||||
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||||
|
|
||||||
data = [
|
data = []
|
||||||
{
|
for doc in documents:
|
||||||
|
job_stmt = select(IndexJob).where(
|
||||||
|
IndexJob.tenant_id == tenant_id,
|
||||||
|
IndexJob.doc_id == doc.id,
|
||||||
|
).order_by(IndexJob.created_at.desc())
|
||||||
|
job_result = await session.execute(job_stmt)
|
||||||
|
latest_job = job_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
data.append({
|
||||||
"docId": str(doc.id),
|
"docId": str(doc.id),
|
||||||
"kbId": doc.kb_id,
|
"kbId": doc.kb_id,
|
||||||
"fileName": doc.file_name,
|
"fileName": doc.file_name,
|
||||||
"status": doc.status,
|
"status": doc.status,
|
||||||
|
"jobId": str(latest_job.id) if latest_job else None,
|
||||||
"createdAt": doc.created_at.isoformat() + "Z",
|
"createdAt": doc.created_at.isoformat() + "Z",
|
||||||
"updatedAt": doc.updated_at.isoformat() + "Z",
|
"updatedAt": doc.updated_at.isoformat() + "Z",
|
||||||
}
|
})
|
||||||
for doc in documents
|
|
||||||
]
|
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
|
|
@ -109,6 +168,7 @@ async def list_documents(
|
||||||
async def upload_document(
|
async def upload_document(
|
||||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
kb_id: str = Form(...),
|
kb_id: str = Form(...),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
|
|
@ -133,7 +193,11 @@ async def upload_document(
|
||||||
file_type=file.content_type,
|
file_type=file.content_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
_schedule_indexing(tenant_id, str(job.id), str(document.id), file_content)
|
await session.commit()
|
||||||
|
|
||||||
|
background_tasks.add_task(
|
||||||
|
_index_document, tenant_id, str(job.id), str(document.id), file_content
|
||||||
|
)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=202,
|
status_code=202,
|
||||||
|
|
@ -145,20 +209,18 @@ async def upload_document(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _schedule_indexing(tenant_id: str, job_id: str, doc_id: str, content: bytes):
|
async def _index_document(tenant_id: str, job_id: str, doc_id: str, content: bytes):
|
||||||
"""
|
"""
|
||||||
Schedule background indexing task.
|
Background indexing task.
|
||||||
For MVP, we simulate indexing with a simple text extraction.
|
For MVP, we simulate indexing with a simple text extraction.
|
||||||
In production, this would use a task queue like Celery.
|
In production, this would use a task queue like Celery.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
async def run_indexing():
|
|
||||||
from app.core.database import async_session_maker
|
from app.core.database import async_session_maker
|
||||||
from app.services.kb import KBService
|
from app.services.kb import KBService
|
||||||
from app.core.qdrant_client import get_qdrant_client
|
from app.core.qdrant_client import get_qdrant_client
|
||||||
from qdrant_client.models import PointStruct
|
from qdrant_client.models import PointStruct
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import asyncio
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
@ -168,6 +230,7 @@ def _schedule_indexing(tenant_id: str, job_id: str, doc_id: str, content: bytes)
|
||||||
await kb_service.update_job_status(
|
await kb_service.update_job_status(
|
||||||
tenant_id, job_id, IndexJobStatus.PROCESSING.value, progress=10
|
tenant_id, job_id, IndexJobStatus.PROCESSING.value, progress=10
|
||||||
)
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
text = content.decode("utf-8", errors="ignore")
|
text = content.decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
|
|
@ -210,6 +273,7 @@ def _schedule_indexing(tenant_id: str, job_id: str, doc_id: str, content: bytes)
|
||||||
await kb_service.update_job_status(
|
await kb_service.update_job_status(
|
||||||
tenant_id, job_id, IndexJobStatus.COMPLETED.value, progress=100
|
tenant_id, job_id, IndexJobStatus.COMPLETED.value, progress=100
|
||||||
)
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[AC-ASA-01] Indexing completed: tenant={tenant_id}, "
|
f"[AC-ASA-01] Indexing completed: tenant={tenant_id}, "
|
||||||
|
|
@ -218,12 +282,14 @@ def _schedule_indexing(tenant_id: str, job_id: str, doc_id: str, content: bytes)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[AC-ASA-01] Indexing failed: {e}")
|
logger.error(f"[AC-ASA-01] Indexing failed: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
async with async_session_maker() as error_session:
|
||||||
|
kb_service = KBService(error_session)
|
||||||
await kb_service.update_job_status(
|
await kb_service.update_job_status(
|
||||||
tenant_id, job_id, IndexJobStatus.FAILED.value,
|
tenant_id, job_id, IndexJobStatus.FAILED.value,
|
||||||
progress=0, error_msg=str(e)
|
progress=0, error_msg=str(e)
|
||||||
)
|
)
|
||||||
|
await error_session.commit()
|
||||||
asyncio.create_task(run_indexing())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ async def list_sessions(
|
||||||
|
|
||||||
data.append({
|
data.append({
|
||||||
"sessionId": s.session_id,
|
"sessionId": s.session_id,
|
||||||
|
"tenantId": tenant_id,
|
||||||
"status": session_status,
|
"status": session_status,
|
||||||
"startTime": s.created_at.isoformat() + "Z",
|
"startTime": s.created_at.isoformat() + "Z",
|
||||||
"endTime": end_time_val,
|
"endTime": end_time_val,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.api import chat_router, health_router
|
from app.api import chat_router, health_router
|
||||||
from app.api.admin import kb_router, rag_router, sessions_router
|
from app.api.admin import dashboard_router, kb_router, rag_router, sessions_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.database import close_db, init_db
|
from app.core.database import close_db, init_db
|
||||||
from app.core.exceptions import (
|
from app.core.exceptions import (
|
||||||
|
|
@ -112,6 +112,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(chat_router)
|
app.include_router(chat_router)
|
||||||
|
|
||||||
|
app.include_router(dashboard_router)
|
||||||
app.include_router(kb_router)
|
app.include_router(kb_router)
|
||||||
app.include_router(rag_router)
|
app.include_router(rag_router)
|
||||||
app.include_router(sessions_router)
|
app.include_router(sessions_router)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ class KBService:
|
||||||
Get existing KB or create default one.
|
Get existing KB or create default one.
|
||||||
"""
|
"""
|
||||||
if kb_id:
|
if kb_id:
|
||||||
|
try:
|
||||||
stmt = select(KnowledgeBase).where(
|
stmt = select(KnowledgeBase).where(
|
||||||
KnowledgeBase.tenant_id == tenant_id,
|
KnowledgeBase.tenant_id == tenant_id,
|
||||||
KnowledgeBase.id == uuid.UUID(kb_id),
|
KnowledgeBase.id == uuid.UUID(kb_id),
|
||||||
|
|
@ -53,6 +54,8 @@ class KBService:
|
||||||
existing_kb = result.scalar_one_or_none()
|
existing_kb = result.scalar_one_or_none()
|
||||||
if existing_kb:
|
if existing_kb:
|
||||||
return existing_kb
|
return existing_kb
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
stmt = select(KnowledgeBase).where(
|
stmt = select(KnowledgeBase).where(
|
||||||
KnowledgeBase.tenant_id == tenant_id,
|
KnowledgeBase.tenant_id == tenant_id,
|
||||||
|
|
@ -276,3 +279,16 @@ class KBService:
|
||||||
|
|
||||||
logger.info(f"[AC-ASA-08] Deleted document: tenant={tenant_id}, doc_id={doc_id}")
|
logger.info(f"[AC-ASA-08] Deleted document: tenant={tenant_id}, doc_id={doc_id}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def list_knowledge_bases(
|
||||||
|
self,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> Sequence[KnowledgeBase]:
|
||||||
|
"""
|
||||||
|
List all knowledge bases for a tenant.
|
||||||
|
"""
|
||||||
|
stmt = select(KnowledgeBase).where(
|
||||||
|
KnowledgeBase.tenant_id == tenant_id
|
||||||
|
).order_by(col(KnowledgeBase.created_at).desc())
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue