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'
|
||||
|
||||
/**
|
||||
* 查询知识库列表
|
||||
*/
|
||||
export function listKnowledgeBases() {
|
||||
return request({
|
||||
url: '/admin/kb/knowledge-bases',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询文档列表 [AC-ASA-08]
|
||||
*/
|
||||
|
|
@ -31,3 +41,13 @@ export function getIndexJob(jobId: string) {
|
|||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文档 [AC-ASA-08]
|
||||
*/
|
||||
export function deleteDocument(docId: string) {
|
||||
return request({
|
||||
url: `/admin/kb/documents/${docId}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,66 @@
|
|||
<template>
|
||||
<div class="dashboard-container">
|
||||
<el-row :gutter="20">
|
||||
<el-row :gutter="20" v-loading="loading">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<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-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<template #header>总消息数</template>
|
||||
<div class="card-content">1,284</div>
|
||||
<div class="card-content">{{ stats.totalMessages.toLocaleString() }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<template #header>平均响应时间</template>
|
||||
<div class="card-content">1.2s</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover">
|
||||
<template #header>活跃租户</template>
|
||||
<div class="card-content">5</div>
|
||||
<template #header>会话总数</template>
|
||||
<div class="card-content">{{ stats.totalSessions }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</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>
|
||||
.dashboard-container {
|
||||
padding: 20px;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<el-table-column label="操作" width="180">
|
||||
<template #default="scope">
|
||||
<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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
|
@ -54,10 +54,11 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { uploadDocument, listDocuments, getIndexJob } from '@/api/kb'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { uploadDocument, listDocuments, getIndexJob, deleteDocument } from '@/api/kb'
|
||||
|
||||
interface DocumentItem {
|
||||
docId: string
|
||||
name: string
|
||||
status: string
|
||||
jobId: string
|
||||
|
|
@ -95,9 +96,10 @@ const fetchDocuments = async () => {
|
|||
try {
|
||||
const res = await listDocuments({})
|
||||
tableData.value = res.data.map((doc: any) => ({
|
||||
docId: doc.docId,
|
||||
name: doc.fileName,
|
||||
status: doc.status,
|
||||
jobId: doc.docId,
|
||||
jobId: doc.jobId,
|
||||
createTime: new Date(doc.createdAt).toLocaleString('zh-CN')
|
||||
}))
|
||||
} catch (error) {
|
||||
|
|
@ -124,6 +126,24 @@ const handleViewJob = async (row: DocumentItem) => {
|
|||
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 () => {
|
||||
if (currentJob.value?.jobId) {
|
||||
currentJob.value = await fetchJobStatus(currentJob.value.jobId)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 使用 BaseTable 展示会话列表 [AC-ASA-09] -->
|
||||
<base-table
|
||||
:data="tableData"
|
||||
:total="total"
|
||||
|
|
@ -31,8 +30,8 @@
|
|||
@pagination="getList"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-table-column prop="sessionId" label="会话 ID" width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="tenantId" label="租户 ID" width="120" />
|
||||
<el-table-column prop="sessionId" label="会话 ID" width="280" show-overflow-tooltip />
|
||||
<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="status" label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
|
|
@ -41,6 +40,7 @@
|
|||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="channelType" label="渠道" width="100" />
|
||||
<el-table-column prop="startTime" label="开始时间" width="180" />
|
||||
<el-table-column label="操作" fixed="right" width="120" align="center">
|
||||
<template #default="scope">
|
||||
|
|
@ -50,7 +50,6 @@
|
|||
</base-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 全链路追踪详情抽屉 [AC-ASA-07] -->
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
title="会话全链路追踪详情"
|
||||
|
|
@ -59,7 +58,15 @@
|
|||
>
|
||||
<div v-loading="detailLoading" class="detail-container">
|
||||
<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
|
||||
v-for="(msg, index) in sessionDetail?.messages"
|
||||
:key="index"
|
||||
|
|
@ -72,12 +79,17 @@
|
|||
<span class="role-tag" :class="msg.role">{{ msg.role === 'user' ? 'USER' : 'ASSISTANT' }}</span>
|
||||
</div>
|
||||
<div class="msg-content">{{ msg.content }}</div>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
|
||||
<!-- 展示追踪信息:检索命中、工具调用等 [AC-ASA-07] -->
|
||||
<div v-if="msg.trace" class="trace-info">
|
||||
<el-collapse class="trace-collapse">
|
||||
<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-divider content-position="left" v-if="sessionDetail?.trace && (sessionDetail.trace.retrieval?.length || sessionDetail.trace.tools?.length)">
|
||||
追踪信息
|
||||
</el-divider>
|
||||
|
||||
<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">
|
||||
<el-tag size="small" type="success">Score: {{ hit.score }}</el-tag>
|
||||
<span class="hit-source" v-if="hit.source">来源: {{ hit.source }}</span>
|
||||
|
|
@ -85,14 +97,11 @@
|
|||
<div class="hit-text">{{ hit.content }}</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item v-if="msg.trace.tool_calls" title="工具调用 (Tool Calls)" name="tools">
|
||||
<pre class="code-block"><code>{{ JSON.stringify(msg.trace.tool_calls, null, 2) }}</code></pre>
|
||||
<el-collapse-item v-if="sessionDetail.trace.tools?.length" title="工具调用 (Tool Calls)" name="tools">
|
||||
<pre class="code-block"><code>{{ JSON.stringify(sessionDetail.trace.tools, null, 2) }}</code></pre>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
|
|
@ -128,6 +137,8 @@ const getList = async () => {
|
|||
const res: any = await listSessions(queryParams)
|
||||
tableData.value = res.data || []
|
||||
total.value = res.pagination?.total || 0
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sessions:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
|
@ -143,12 +154,13 @@ const resetQuery = () => {
|
|||
handleQuery()
|
||||
}
|
||||
|
||||
/** 获取并展示全链路追踪详情 [AC-ASA-07] */
|
||||
const handleTrace = async (row: any) => {
|
||||
drawerVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
sessionDetail.value = await getSessionDetail(row.sessionId)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch session detail:', error)
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
|
@ -164,15 +176,13 @@ onMounted(() => {
|
|||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.title { font-size: 16px; font-weight: bold; }
|
||||
.detail-container { padding: 10px 20px; }
|
||||
.session-info { margin-bottom: 20px; }
|
||||
.msg-card { border-radius: 8px; margin-bottom: 10px; }
|
||||
.msg-header { margin-bottom: 8px; }
|
||||
.role-tag { font-size: 11px; font-weight: bold; padding: 2px 6px; border-radius: 4px; }
|
||||
.role-tag.user { background-color: #ecf5ff; color: #409eff; }
|
||||
.role-tag.assistant { background-color: #f0f9eb; color: #67c23a; }
|
||||
.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-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.hit-source { font-size: 11px; color: #999; }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<template>
|
||||
<div class="rag-lab-container">
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧:调试输入 [AC-ASA-05] -->
|
||||
<el-col :span="10">
|
||||
<el-card header="调试输入">
|
||||
<el-form label-position="top">
|
||||
|
|
@ -19,8 +18,14 @@
|
|||
multiple
|
||||
placeholder="请选择知识库"
|
||||
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-form-item>
|
||||
<el-form-item label="参数配置">
|
||||
|
|
@ -46,7 +51,6 @@
|
|||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧:实验结果 [AC-ASA-05] -->
|
||||
<el-col :span="14">
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<el-tab-pane label="召回片段" name="retrieval">
|
||||
|
|
@ -76,6 +80,14 @@
|
|||
<pre><code>{{ results.finalPrompt }}</code></pre>
|
||||
</div>
|
||||
</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-col>
|
||||
</el-row>
|
||||
|
|
@ -83,16 +95,25 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { runRagExperiment } from '@/api/rag'
|
||||
import { listKnowledgeBases } from '@/api/kb'
|
||||
|
||||
interface KnowledgeBase {
|
||||
id: string
|
||||
name: string
|
||||
documentCount: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const kbLoading = ref(false)
|
||||
const activeTab = ref('retrieval')
|
||||
const knowledgeBases = ref<KnowledgeBase[]>([])
|
||||
|
||||
const queryParams = reactive({
|
||||
query: '',
|
||||
kbIds: [],
|
||||
kbIds: [] as string[],
|
||||
params: {
|
||||
topK: 3,
|
||||
threshold: 0.5
|
||||
|
|
@ -100,11 +121,23 @@ const queryParams = reactive({
|
|||
})
|
||||
|
||||
const results = reactive({
|
||||
retrievalResults: [],
|
||||
finalPrompt: ''
|
||||
retrievalResults: [] as any[],
|
||||
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 () => {
|
||||
if (!queryParams.query.trim()) {
|
||||
ElMessage.warning('请输入查询 Query')
|
||||
|
|
@ -116,14 +149,20 @@ const handleRun = async () => {
|
|||
const res: any = await runRagExperiment(queryParams)
|
||||
results.retrievalResults = res.retrievalResults || []
|
||||
results.finalPrompt = res.finalPrompt || ''
|
||||
results.diagnostics = res.diagnostics || null
|
||||
activeTab.value = 'retrieval'
|
||||
ElMessage.success('实验运行成功')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
ElMessage.error('实验运行失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchKnowledgeBases()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -148,14 +187,14 @@ const handleRun = async () => {
|
|||
}
|
||||
.source { font-size: 12px; color: #909399; }
|
||||
.result-content { font-size: 14px; line-height: 1.6; color: #303133; }
|
||||
.prompt-view {
|
||||
.prompt-view, .diagnostics-view {
|
||||
background-color: #f5f7fa;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.prompt-view pre {
|
||||
.prompt-view pre, .diagnostics-view pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
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.
|
||||
"""
|
||||
|
||||
from app.api.admin.dashboard import router as dashboard_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.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
|
||||
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 sqlalchemy import select
|
||||
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 DocumentStatus, IndexJobStatus
|
||||
from app.models.entities import DocumentStatus, IndexJob, IndexJobStatus
|
||||
from app.services.kb import KBService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -32,6 +33,57 @@ def get_current_tenant_id() -> str:
|
|||
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(
|
||||
"/documents",
|
||||
operation_id="listDocuments",
|
||||
|
|
@ -70,17 +122,24 @@ async def list_documents(
|
|||
|
||||
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),
|
||||
"kbId": doc.kb_id,
|
||||
"fileName": doc.file_name,
|
||||
"status": doc.status,
|
||||
"jobId": str(latest_job.id) if latest_job else None,
|
||||
"createdAt": doc.created_at.isoformat() + "Z",
|
||||
"updatedAt": doc.updated_at.isoformat() + "Z",
|
||||
}
|
||||
for doc in documents
|
||||
]
|
||||
})
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
|
|
@ -109,6 +168,7 @@ async def list_documents(
|
|||
async def upload_document(
|
||||
tenant_id: Annotated[str, Depends(get_current_tenant_id)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
background_tasks: BackgroundTasks,
|
||||
file: UploadFile = File(...),
|
||||
kb_id: str = Form(...),
|
||||
) -> JSONResponse:
|
||||
|
|
@ -133,7 +193,11 @@ async def upload_document(
|
|||
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(
|
||||
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.
|
||||
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.services.kb import KBService
|
||||
from app.core.qdrant_client import get_qdrant_client
|
||||
from qdrant_client.models import PointStruct
|
||||
import hashlib
|
||||
import asyncio
|
||||
|
||||
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(
|
||||
tenant_id, job_id, IndexJobStatus.PROCESSING.value, progress=10
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
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(
|
||||
tenant_id, job_id, IndexJobStatus.COMPLETED.value, progress=100
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
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:
|
||||
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(
|
||||
tenant_id, job_id, IndexJobStatus.FAILED.value,
|
||||
progress=0, error_msg=str(e)
|
||||
)
|
||||
|
||||
asyncio.create_task(run_indexing())
|
||||
await error_session.commit()
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ async def list_sessions(
|
|||
|
||||
data.append({
|
||||
"sessionId": s.session_id,
|
||||
"tenantId": tenant_id,
|
||||
"status": session_status,
|
||||
"startTime": s.created_at.isoformat() + "Z",
|
||||
"endTime": end_time_val,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from fastapi.responses import JSONResponse
|
||||
|
||||
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.database import close_db, init_db
|
||||
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(chat_router)
|
||||
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(kb_router)
|
||||
app.include_router(rag_router)
|
||||
app.include_router(sessions_router)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class KBService:
|
|||
Get existing KB or create default one.
|
||||
"""
|
||||
if kb_id:
|
||||
try:
|
||||
stmt = select(KnowledgeBase).where(
|
||||
KnowledgeBase.tenant_id == tenant_id,
|
||||
KnowledgeBase.id == uuid.UUID(kb_id),
|
||||
|
|
@ -53,6 +54,8 @@ class KBService:
|
|||
existing_kb = result.scalar_one_or_none()
|
||||
if existing_kb:
|
||||
return existing_kb
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
stmt = select(KnowledgeBase).where(
|
||||
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}")
|
||||
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